<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: martinimarcello00</title>
    <description>The latest articles on DEV Community by martinimarcello00 (@martinimarcello00).</description>
    <link>https://dev.to/martinimarcello00</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F155611%2Fbbc52ebe-cbb8-49aa-8a06-12214178da25.png</url>
      <title>DEV Community: martinimarcello00</title>
      <link>https://dev.to/martinimarcello00</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/martinimarcello00"/>
    <language>en</language>
    <item>
      <title>I put an LLM agent on a static site for free. Here is the actual bill</title>
      <dc:creator>martinimarcello00</dc:creator>
      <pubDate>Thu, 10 Sep 2026 15:22:35 +0000</pubDate>
      <link>https://dev.to/martinimarcello00/i-put-an-llm-agent-on-a-static-site-for-free-here-is-the-actual-bill-1217</link>
      <guid>https://dev.to/martinimarcello00/i-put-an-llm-agent-on-a-static-site-for-free-here-is-the-actual-bill-1217</guid>
      <description>&lt;p&gt;The &lt;a href="https://marcellomartini.tech/posts/how-this-site-is-built/" rel="noopener noreferrer"&gt;previous post&lt;/a&gt; made a claim I still stand behind: this site has no server, no database and nothing to patch. It is a repository that becomes HTML.&lt;/p&gt;

&lt;p&gt;Then there is a button in the bottom right corner that answers questions about me, in full sentences, out of a language model. Both things are true at the same time. The second one is only free at the point where you happen to be looking.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the agent actually runs
&lt;/h2&gt;

&lt;p&gt;Not here. It is a Docker image on a &lt;a href="https://huggingface.co/spaces/martinimarcello00/personal-cv-langgraph" rel="noopener noreferrer"&gt;Hugging Face Space&lt;/a&gt;, on the free CPU tier, two shared cores. A GitHub Action pushes the repository to the Space on every commit to &lt;code&gt;main&lt;/code&gt;, so shipping the agent is still &lt;code&gt;git push&lt;/code&gt;, the same gesture that ships the site.&lt;/p&gt;

&lt;p&gt;The site's half of the contract is one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;CHAT_ENDPOINT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://martinimarcello00-personal-cv-langgraph.hf.space/chat&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the whole dynamic part of a static site. Everything in &lt;code&gt;dist/&lt;/code&gt; is still a file, and the one thing that thinks lives somewhere else behind a URL. The architecture did not get simpler. It moved.&lt;/p&gt;

&lt;h2&gt;
  
  
  Free means it falls asleep
&lt;/h2&gt;

&lt;p&gt;A free Space sleeps after 48 hours without traffic, and only paid hardware can be told not to. The next visitor restarts it, which sounds harmless until you read what the container does when it boots:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; ["/bin/bash", "-c", "python build_rag.py &amp;amp;&amp;amp; uvicorn api:app --host 0.0.0.0 --port 7860"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The vector index is not baked into the image. It is rebuilt at startup, every document re-embedded with &lt;code&gt;bge-small-en-v1.5&lt;/code&gt; on those two shared cores before the first request gets an answer. Somebody arriving after a quiet weekend is not waiting for a container to start. They are waiting for an embedding job to finish.&lt;/p&gt;

&lt;p&gt;So the Space is never allowed to be idle. The monitor that already watches the containers on &lt;a href="https://marcellomartini.tech/posts/containers-that-run-my-home-server/" rel="noopener noreferrer"&gt;my home server&lt;/a&gt; watches this one too.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://uptime.kuma.pet/" rel="noopener noreferrer"&gt;Uptime Kuma&lt;/a&gt;&lt;/strong&gt; — The thing that notices before I do.&lt;/p&gt;

&lt;p&gt;Scheduled checks against the handful of endpoints that are supposed to answer, with a notification when one stops. The job I did not expect to give it is keeping something awake rather than watching it: a free Hugging Face Space sleeps after two days of silence, and a periodic request to its health check is what stops the assistant on this site from booting cold in front of a visitor.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The detail that makes it cheap is the target. &lt;code&gt;api.py&lt;/code&gt; exposes a health check next to the chat endpoint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@app.get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/health&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;health_check&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ok&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The monitor calls &lt;code&gt;/health&lt;/code&gt; every five minutes and never calls &lt;code&gt;/chat&lt;/code&gt;. It costs a TCP connection and zero tokens. Pointing a keepalive at the chat endpoint instead would mean paying a language model 288 times a day, forever, to tell a machine that it is still awake.&lt;/p&gt;

&lt;p&gt;Five minutes against a threshold of 48 hours is far more often than the problem requires, and I am keeping it, because a check that frequent is also answering the question the monitor was installed for: not only is the agent awake, it is reachable, and I find out before a visitor does.&lt;/p&gt;

&lt;p&gt;That is the first tradeoff and it deserves saying plainly: the free tier is free because it expects to be idle, and I am using hardware I own to guarantee that it never is. Somebody else's free hosting, kept alive by my electricity bill.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bill is the real limit
&lt;/h2&gt;

&lt;p&gt;Sleeping is an inconvenience. The API is the part that can actually cost money, because every visitor is spending mine.&lt;/p&gt;

&lt;p&gt;Two of the three guards are the ones everybody writes. A rate limit per address:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@app.post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/chat&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nd"&gt;@limiter.limit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;5/minute&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And a CORS list with my domain on it, which is worth being honest about. It stops a script on another site from using my endpoint from somebody's browser, and it stops nothing else. The Space has its own public interface one click away, and CORS was never an authentication mechanism.&lt;/p&gt;

&lt;p&gt;The third guard is the one I would copy into any project shaped like this one. Before answering anything, the app asks the provider how much it has already spent today:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;usage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_today_model_usage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gpt-5-nano&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;total_tokens&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;daily_limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;response&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;I&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;m currently overwhelmed with fame (and API token limits)...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The budget is not a counter in a variable that resets when the container restarts. It is the organization usage endpoint, for the current UTC day, grouped by model. A crash does not hand out a fresh allowance, and neither does a redeploy.&lt;/p&gt;

&lt;p&gt;Notice what the visitor gets when it runs out. A joke, not a 429. When a failure is guaranteed to happen eventually and it is not the visitor's fault, the degraded state stops being an error path and becomes a copy decision.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the budget buys, and what it costs
&lt;/h2&gt;

&lt;p&gt;The choices that make that budget last are all visible in the code, and they are the same choices that decide how the thing feels. The model is &lt;code&gt;gpt-5-nano&lt;/code&gt;. Memory is &lt;code&gt;get_safe_history(messages, k=4)&lt;/code&gt;, the last four messages, walked further back if the cut would land in the middle of a tool call. Retrieval mixes vectors with BM25, and the BM25 half is rebuilt from every document in the store on each query.&lt;/p&gt;

&lt;p&gt;From the comment I left in the widget after measuring it: 17 seconds for a cold question, over 25 for a follow-up. Follow-ups are slower than first questions, which is the opposite of what anyone expects, because the thread travels with the request.&lt;/p&gt;

&lt;p&gt;There is a stranger consequence hiding in the same file. The checkpointer is &lt;code&gt;MemorySaver&lt;/code&gt;, which lives in the process. The browser stores a thread id and keeps the transcript, so a conversation survives navigation, a reload and my next deploy. The agent's copy of it does not survive the restart. The visitor is holding a conversation the server has already forgotten, and it looks fine right up until it does not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The front end pays whatever is left
&lt;/h2&gt;

&lt;p&gt;None of that can be fixed for free, so the interface has to absorb it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;killTimer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;timeout&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;90000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ninety seconds before giving up, a counter that appears once the wait passes five, and a line under the typing dots that says what is going on: &lt;em&gt;"Still thinking, 12s. It runs on a free box and takes its time."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Naming the constraint costs nothing and changes the entire reading of the wait. Twenty five silent seconds are indistinguishable from something broken. The same twenty five seconds with a number attached are a slow machine, which is exactly what it is.&lt;/p&gt;

&lt;p&gt;The obvious next move is already half built. &lt;code&gt;api.py&lt;/code&gt; also exposes &lt;code&gt;/chat/stream&lt;/code&gt;, and the site does not call it. The wait would stop being one opaque block and start being text arriving. That is a commit, not a subscription.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually came back
&lt;/h2&gt;

&lt;p&gt;The site is still static. Nothing returned that I have to patch, back up or keep running. What returned is a budget, a monitor and a sentence admitting the box is free.&lt;/p&gt;

&lt;p&gt;Free hosting for something that thinks is real, and this is the price on the label: it sleeps unless something wakes it, it forgets unless somebody pays for memory, it is slow unless the answer is cheap, and it stops when the day's tokens are gone. All four are acceptable for an assistant on a personal site. None of them are invisible, and pretending otherwise is how you end up with a chat button that has been broken for a week before anyone bothers to tell you.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://marcellomartini.tech/projects/interactive-portfolio-agent/" rel="noopener noreferrer"&gt;How the agent is built&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This one first appeared on &lt;a href="https://marcellomartini.tech/posts/chatbot-without-a-server/" rel="noopener noreferrer"&gt;my site&lt;/a&gt;, where I write about the systems I build and the tools I actually use — &lt;a href="https://marcellomartini.tech/posts/" rel="noopener noreferrer"&gt;the rest of the posts live here&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>webdev</category>
      <category>selfhosted</category>
    </item>
    <item>
      <title>Real HTTPS for self-hosted services, without opening a single port</title>
      <dc:creator>martinimarcello00</dc:creator>
      <pubDate>Tue, 01 Sep 2026 07:33:33 +0000</pubDate>
      <link>https://dev.to/martinimarcello00/real-https-for-self-hosted-services-without-opening-a-single-port-581a</link>
      <guid>https://dev.to/martinimarcello00/real-https-for-self-hosted-services-without-opening-a-single-port-581a</guid>
      <description>&lt;p&gt;My home server is an old desktop with four cores and 8 GB of RAM running OpenMediaVault, and it has thirty three containers up right now. A third of them are the usual media stack, which the internet has documented to death, so this is about the interesting part: of those thirty three, only two decide what is reachable and by whom.&lt;/p&gt;

&lt;h2&gt;
  
  
  Nothing listens on my network
&lt;/h2&gt;

&lt;p&gt;Almost every compose file on that machine has the same shape:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;immich-server&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;127.0.0.1:2283:2283"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That prefix is the entire security model. The port exists on loopback only, so no device on my LAN can reach the container by address, not even by accident, and a container I misconfigure cannot quietly become a public service.&lt;/p&gt;

&lt;p&gt;Two containers break the rule on purpose. Pi-hole, because it has to answer DNS on port 53 for the whole house, and Nginx Proxy Manager, which owns 80 and 443. Everything else is reachable only if the proxy has been told a hostname for it.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://nginxproxymanager.com/" rel="noopener noreferrer"&gt;Nginx Proxy Manager&lt;/a&gt;&lt;/strong&gt; — One door in front of thirty containers.&lt;/p&gt;

&lt;p&gt;Every service on my home server binds to loopback, so this proxy is the only way in and the only place that decides what exists. It terminates HTTPS with real Let's Encrypt certificates issued over the DNS challenge, which means hostnames that resolve to a private address still get a genuine padlock instead of a self-signed warning. Adding a host takes two minutes in the web UI, which is the reason the loopback rule never gets bent.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  HTTPS for names the internet cannot reach
&lt;/h2&gt;

&lt;p&gt;Internal services live on subdomains of a domain I own. Those DNS records are public, and they answer with the private address the server holds on my LAN. Anyone can resolve the name; nobody outside the flat can route to it.&lt;/p&gt;

&lt;p&gt;That is also what makes the certificates work. Nginx Proxy Manager asks Let's Encrypt over the DNS challenge, which proves I control the domain by writing a TXT record rather than by accepting an inbound connection. A service the internet cannot reach therefore still gets a genuine certificate: no self-signed warnings, no private CA to install on every phone, a normal padlock everywhere.&lt;/p&gt;

&lt;p&gt;Pi-hole answers those names on the LAN and blocks ads for every device while it is at it, including the ones that will never run an extension.&lt;/p&gt;

&lt;p&gt;Two things break this, and neither is in the tutorials.&lt;/p&gt;

&lt;p&gt;The first is DNS rebinding protection. A resolver that guards against it throws away any answer pointing at a private address, which is exactly what my records do, so the name resolves to nothing and the service looks dead. Plenty of routers ship with the protection on, dnsmasq and Pi-hole can do the same, and the fix is to whitelist the one domain rather than to switch the whole thing off.&lt;/p&gt;

&lt;p&gt;The second is the browser. Firefox and Chrome will happily send DNS over HTTPS to a public resolver, walking straight past the machine that knows the internal names. Everything works on one laptop and fails on another, and the difference is a setting nobody remembers changing.&lt;/p&gt;

&lt;p&gt;The price of the DNS challenge is worth saying out loud too: an API token for the DNS provider now lives on the server. Scope it to the single zone it needs, never to the account.&lt;/p&gt;

&lt;h2&gt;
  
  
  The VPN is the way in
&lt;/h2&gt;

&lt;p&gt;Tailscale runs as a container advertising the route of my home subnet. My laptop and my phone are on the same mesh, so away from home they resolve and reach exactly the same hostnames as they do from the sofa, over WireGuard, with the certificates still valid.&lt;/p&gt;

&lt;p&gt;The router has no port forwarding, no dynamic DNS and no UPnP. The attack surface of the whole setup is one Tailscale identity.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://tailscale.com/" rel="noopener noreferrer"&gt;Tailscale&lt;/a&gt;&lt;/strong&gt; — My machines behave as if they were on one LAN.&lt;/p&gt;

&lt;p&gt;A WireGuard mesh across my laptop, phone and home server, so the self-hosted things stay off the public internet while remaining one hostname away from anywhere. It removed the last reason I had to open a port on my router.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The one exception, and it dials out
&lt;/h2&gt;

&lt;p&gt;Webhooks are the case a VPN cannot solve: a third party has to POST to a URL that really exists on the internet. That single hostname goes through a Cloudflare tunnel. The container opens an outbound connection to Cloudflare, Cloudflare terminates TLS at the edge and pushes requests back down the same connection to n8n. One name is public, my address is not, and there is still nothing listening at home.&lt;/p&gt;

&lt;h2&gt;
  
  
  What runs behind all that
&lt;/h2&gt;

&lt;p&gt;Home Assistant is the container I would restore first. Every sensor, plug and light in the flat talks to it locally, so the house does not stop working when the line does, and a year of energy readings sits on my disk instead of in someone's analytics pipeline. ESPHome compiles the firmware for the cheap microcontrollers I use as sensors, a Matter server handles the devices that speak it, and n8n deals with whatever has to leave the house.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://www.home-assistant.io/" rel="noopener noreferrer"&gt;Home Assistant&lt;/a&gt;&lt;/strong&gt; — The house runs locally or it does not run.&lt;/p&gt;

&lt;p&gt;Every sensor, plug and light talks to a container in my flat rather than a vendor cloud, so automations keep working when the internet is down and the history stays on my disk. Integrations cover the devices I already owned, and ESPHome turns a five euro microcontroller into a first class sensor with a few lines of YAML.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The rest is what I open every day. Immich replaced Google Photos properly, faces and search included. Karakeep archives the links I mean to read, with Meilisearch behind it. Calibre-web feeds the Kindle. Five small containers watch the others: Portainer, Dozzle for logs, Beszel for CPU and temperature history, What's Up Docker for image updates, and Homepage as the front page that ties them together.&lt;/p&gt;

&lt;h2&gt;
  
  
  The unglamorous half
&lt;/h2&gt;

&lt;p&gt;None of this matters if the disk dies, and exactly one container holds something I cannot download again. The photos are the only irreplaceable data on the machine; everything else is a compose file, a config directory and an afternoon.&lt;/p&gt;

&lt;p&gt;So Immich is treated differently. Borg backs it up to a Hetzner storage box, encrypted before it leaves the house and deduplicated, so keeping a long history costs almost nothing and a bad run cannot quietly overwrite the good copies. The compose files ride along in the same archive, which is what turns "rebuild the server" from a project into a chore.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://immich.app/" rel="noopener noreferrer"&gt;Immich&lt;/a&gt;&lt;/strong&gt; — The photo library that stopped being rented.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://www.borgbackup.org/" rel="noopener noreferrer"&gt;BorgBackup&lt;/a&gt;&lt;/strong&gt; — The only container whose failure would actually hurt.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Two containers out of thirty three do the work that matters: the proxy that decides what exists and the VPN that decides who is inside. One backup covers the only data that cannot be recreated. Everything else is replaceable in an afternoon, which is the whole point of building it this way.&lt;/p&gt;

&lt;p&gt;Home Assistant is the oldest thing on that machine by a decade. The lights, sensors and buttons it drives started on a Raspberry Pi in 2017 and moved here once the server existed, and that side of the house is written up separately under &lt;a href="https://marcellomartini.tech/projects/home-automation/" rel="noopener noreferrer"&gt;Home automation&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://marcellomartini.tech/tools/" rel="noopener noreferrer"&gt;See the full toolbox&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This one first appeared on &lt;a href="https://marcellomartini.tech/posts/containers-that-run-my-home-server/" rel="noopener noreferrer"&gt;my site&lt;/a&gt;, where I write about the systems I build and the tools I actually use — &lt;a href="https://marcellomartini.tech/posts/" rel="noopener noreferrer"&gt;the rest of the posts live here&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>selfhosted</category>
      <category>docker</category>
      <category>homelab</category>
      <category>networking</category>
    </item>
    <item>
      <title>I moved my site from Hugo to Astro and kept every URL</title>
      <dc:creator>martinimarcello00</dc:creator>
      <pubDate>Tue, 25 Aug 2026 17:46:44 +0000</pubDate>
      <link>https://dev.to/martinimarcello00/how-this-site-is-built-52eb</link>
      <guid>https://dev.to/martinimarcello00/how-this-site-is-built-52eb</guid>
      <description>&lt;p&gt;Every personal site I built before this one died the same way. A CMS I stopped updating, a database I had to back up, a plugin that broke on a version bump. The fix was to make the site something I already maintain every day: a repository.&lt;/p&gt;

&lt;p&gt;That part has not changed. There is still no server, no database, nothing to patch. What changed is the thing that turns the text files into HTML.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I left Hugo
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://gohugo.io/" rel="noopener noreferrer"&gt;Hugo&lt;/a&gt;&lt;/strong&gt; — A website that is just files in a repository.&lt;/p&gt;

&lt;p&gt;Still the fastest thing I have used for a site that is only pages. A full rebuild in about four hundred milliseconds, which spoils you.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Not for speed. Hugo rebuilt this entire site in roughly four hundred milliseconds. Astro takes about thirteen seconds for forty four pages. On that number alone I should have stayed.&lt;/p&gt;

&lt;p&gt;I left because of what the site had become. It was no longer only pages. There was a chat widget, a photo slideshow with filters, a travel map, a search modal, a theme toggle, a consent banner. Every one of them was hand written JavaScript in a &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; tag, reaching into markup produced by a Go template that knew nothing about it. Nothing checked that the class a script queried still existed. Nothing checked that a field a template read was still in the YAML.&lt;/p&gt;

&lt;p&gt;The trigger was mundane. I renamed a key in a data file and a section quietly vanished from three pages, because Go templates resolve a missing field to the empty string and carry on. That is correct behaviour for a template engine. It is a bad property for a site with a hundred interlocking data fields.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule: not a single URL changes
&lt;/h2&gt;

&lt;p&gt;Before writing any code I set one constraint. Every URL the old site served, the new one serves, at the same address, or the migration failed.&lt;/p&gt;

&lt;p&gt;Two settings carry most of that:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;build&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;format&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;directory&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="nx"&gt;trailingSlash&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;always&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Hugo published everything as &lt;code&gt;&amp;lt;route&amp;gt;/index.html&lt;/code&gt; with a trailing slash. Keeping both means no redirect layer and no changed link.&lt;/p&gt;

&lt;p&gt;The interesting one was casing. Hugo ran with &lt;code&gt;disablePathToLower&lt;/code&gt;, so a file named &lt;code&gt;SE2-Project.md&lt;/code&gt; was served at &lt;code&gt;/projects/SE2-Project/&lt;/code&gt;. Astro's content loader lowercases ids by default, which would have moved that page and broken every inbound link to it without anything looking wrong:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;verbatimId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="sr"&gt;mdx&lt;/span&gt;&lt;span class="se"&gt;?&lt;/span&gt;&lt;span class="sr"&gt;$/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The sitemap stayed hand written for the same reason. The official integration publishes &lt;code&gt;sitemap-index.xml&lt;/code&gt;, and Search Console already knows this site's sitemap as &lt;code&gt;/sitemap.xml&lt;/code&gt;. Writing it myself also lets each entry carry a real &lt;code&gt;lastmod&lt;/code&gt; taken from the git history rather than the build date, which is the more honest answer to when a page last changed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What did not move at all
&lt;/h2&gt;

&lt;p&gt;The content did not. Posts and projects are the same Markdown files with the same front matter. The &lt;code&gt;data/&lt;/code&gt; directory is byte for byte the same YAML and JSON. The Python scripts that refresh publications from ORCID, repositories from GitHub and book covers from Open Library were not touched, because they write JSON and do not care who reads it.&lt;/p&gt;

&lt;p&gt;That is the part worth stealing. The migration was survivable because the content and the data were never coupled to the tool. Only the layer that renders them was.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/features/actions" rel="noopener noreferrer"&gt;GitHub Actions&lt;/a&gt;&lt;/strong&gt; — The only deploy button, and it is a git push.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The workflow kept its most useful property too:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Refresh auto-updating data&lt;/span&gt;
  &lt;span class="c1"&gt;# Non-fatal: on API failure the committed data/*.json is used as fallback&lt;/span&gt;
  &lt;span class="na"&gt;continue-on-error&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;python3 scripts/update_data.py&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The generated JSON is committed. If ORCID is down the step goes red, the build carries on, and the site shows yesterday's data. A stale publication list beats a failed deploy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Islands, and the pages that ship nothing
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://astro.build/" rel="noopener noreferrer"&gt;Astro&lt;/a&gt;&lt;/strong&gt; — Components everywhere, JavaScript only where it earns it.&lt;/p&gt;

&lt;p&gt;A site framework that renders to HTML at build time and ships no client JavaScript unless a component asks for it. The interactive pieces are marked as islands and hydrate on their own; everything around them stays static. It also types the content: Markdown front matter and the data files are parsed through a schema during the build, so renaming a field is an error rather than a silently empty section.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The reason to be on Astro is that a page ships no JavaScript unless something on it needs JavaScript. The interactive parts are components marked as islands. Everything else is HTML produced at build time and then left alone.&lt;/p&gt;

&lt;p&gt;So the photo wall, the chat panel, the tool filters and the search dialog hydrate. The about page, the posts and the project pages ship markup and CSS and nothing else. It is the component model without the tax I expected to pay for it.&lt;/p&gt;

&lt;p&gt;The clearest measurement of that is the diagram library, further down.&lt;/p&gt;

&lt;h2&gt;
  
  
  The data files got a type, and immediately caught me
&lt;/h2&gt;

&lt;p&gt;Every file under &lt;code&gt;data/&lt;/code&gt; is now parsed through a Zod schema at build time. Renaming a key is a build error instead of a blank section, which is exactly what I wanted.&lt;/p&gt;

&lt;p&gt;It also introduced a new way to be wrong, and I walked into it within a week. A tool card silently lost its link. No error, no warning, a green build. The schema declared the field as &lt;code&gt;internal&lt;/code&gt;, the JSON called it &lt;code&gt;internal_url&lt;/code&gt;, and Zod strips keys it does not recognise without saying anything. The value was read, discarded, and rendered as nothing.&lt;/p&gt;

&lt;p&gt;That is the shape of most bugs I hit in this migration, and it deserves a name: a wrong name produces nothing, not an error. A CSS custom property that does not exist, a Tailwind class whose token is not defined, a schema key that does not match the data. All three compile. All three render silence. None appear in a console. The only thing that catches them is reading the computed value in the browser.&lt;/p&gt;

&lt;h2&gt;
  
  
  Social cards, drawn differently
&lt;/h2&gt;

&lt;p&gt;Every page still gets its own 1200x630 preview image, still drawn during the build, still without a headless browser taking screenshots.&lt;/p&gt;

&lt;p&gt;Hugo did it with &lt;code&gt;images.Text&lt;/code&gt;, stamping the title onto a base image. That function does not wrap, so the template split the title into lines by hand, and the variable font rendered at its lightest weight, so I drew the text three times at one pixel offsets to fake a bold.&lt;/p&gt;

&lt;p&gt;Astro has no equivalent, so the card is now laid out with &lt;a href="https://github.com/vercel/satori" rel="noopener noreferrer"&gt;Satori&lt;/a&gt;, which takes something shaped like JSX and produces SVG, then rasterised with resvg. Text wraps because flexbox wraps, and both hacks above disappeared. The cost is a real dependency where there had been a built in function.&lt;/p&gt;

&lt;h2&gt;
  
  
  Search did not change
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://pagefind.app/" rel="noopener noreferrer"&gt;Pagefind&lt;/a&gt; still reads the finished HTML after the build and writes an index next to it. It never cared which generator produced the HTML, which is precisely why it survived the move untouched.&lt;/p&gt;

&lt;p&gt;One attribute still decides what is indexed, and it is also how the pages I keep out of search stay out of it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;data-pagefind-body={noindex ? undefined : true}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Press cmd+K anywhere on the site and it opens.&lt;/p&gt;

&lt;h2&gt;
  
  
  Code blocks, and a bug that was invisible by design
&lt;/h2&gt;

&lt;p&gt;Astro highlights code with Shiki at build time, so no highlighting library reaches the browser. Configure two themes and it writes both into the markup: the light one as inline styles, the dark one as CSS custom properties.&lt;/p&gt;

&lt;p&gt;I configured both, opened a post in dark mode, and the code was still light. There was nothing to debug. Forty two code blocks, two hundred and sixty three &lt;code&gt;--shiki-dark&lt;/code&gt; variables sitting in the HTML, and not one rule reading them. Emitting the variables is Shiki's half of the job. Consuming them is yours:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nt"&gt;html&lt;/span&gt;&lt;span class="nc"&gt;.dark&lt;/span&gt; &lt;span class="nc"&gt;.astro-code&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
&lt;span class="nt"&gt;html&lt;/span&gt;&lt;span class="nc"&gt;.dark&lt;/span&gt; &lt;span class="nc"&gt;.astro-code&lt;/span&gt; &lt;span class="nt"&gt;span&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--shiki-dark&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="cp"&gt;!important&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details are specific to Astro. The class is &lt;code&gt;.astro-code&lt;/code&gt;, not Shiki's &lt;code&gt;.shiki&lt;/code&gt;, so every example you find online needs renaming. And the switch has to key off whatever your theme toggle sets rather than &lt;code&gt;prefers-color-scheme&lt;/code&gt;, or the blocks follow the operating system while the rest of the page follows the button.&lt;/p&gt;

&lt;h2&gt;
  
  
  Diagrams, three times
&lt;/h2&gt;

&lt;p&gt;The single architecture diagram on this site has now been rendered three different ways.&lt;/p&gt;

&lt;p&gt;Under Hugo it pulled Mermaid from a CDN on every page carrying the shortcode. During the migration I stubbed the renderer to return nothing, meaning to come back to it, and the diagram shipped as preformatted text for weeks. The stub was one line and it never complained.&lt;/p&gt;

&lt;p&gt;It now uses an integration that renders in the browser, which sounds like a step backwards until you check where the cost lands:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;mermaid&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;theme&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;neutral&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;autoTheme&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I watched the network on three pages. The privacy page and a blog post make zero requests for Mermaid. The one project page holding a diagram makes one. Ninety six kilobytes, paid by the single page that needs it.&lt;/p&gt;

&lt;p&gt;The build got slower for it, from about ten seconds to thirteen. For one diagram that is the right trade only because the alternative, rendering to SVG during the build, needs a headless browser installed in CI. If this site ever has twenty diagrams, that calculation flips.&lt;/p&gt;

&lt;h2&gt;
  
  
  A full copy of the site, before it is the site
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://pages.cloudflare.com/" rel="noopener noreferrer"&gt;Cloudflare Pages&lt;/a&gt;&lt;/strong&gt; — A full copy of the site to look at before anyone else can.&lt;/p&gt;

&lt;p&gt;Static hosting on Cloudflare's edge, used here for staging rather than production. Every push to a branch that is not master builds the whole site and publishes it to a separate hostname, so a redesign can be read on a real phone over real HTTPS instead of on localhost. The preview is served with a noindex header so it never competes with the live site in search results.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A rewrite lives on a branch for weeks, and reading it on localhost is not the same as reading it on a phone, on a real connection, over real HTTPS. Half the layout bugs in this migration only appeared once the site was somewhere I could open from the sofa.&lt;/p&gt;

&lt;p&gt;So production stays on GitHub Pages and every push to a branch that is not &lt;code&gt;master&lt;/code&gt; builds the whole site and publishes it to a separate host:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches-ignore&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;master"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The part I would not skip is the one line that keeps a full duplicate of the site out of search results:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'/*\n  X-Robots-Tag: noindex, nofollow\n'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; dist/_headers
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without it you have published a second copy of every page, with the same text and different URLs, and invited a search engine to choose between them. The header is unconditional, so there is no configuration to get wrong later.&lt;/p&gt;

&lt;p&gt;The workflow also checks its credentials before it builds rather than after, so a missing secret fails in a few seconds instead of at the end of a full build. That one is a small thing that pays for itself the first time it happens.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Copilot was good for
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/features/copilot" rel="noopener noreferrer"&gt;GitHub Copilot&lt;/a&gt;&lt;/strong&gt; — A fast first draft, never the final word.&lt;/p&gt;

&lt;p&gt;Used the way it is good: a fast first draft on code I can review, never on decisions I have not made yet.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A migration is mostly translation, and translation is what these tools are genuinely good at. Turning a Go template into an Astro component is mechanical work with a right answer, and having it drafted saved real hours.&lt;/p&gt;

&lt;p&gt;Where it helped least is exactly where this post keeps landing. It writes code that compiles and renders nothing, for the same reason I do: the wrong token name and the right token name look equally plausible. All three of the silent bugs above survived review, mine and its, and died to a measurement in the browser.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;&lt;/strong&gt; — Version control is the least interesting part.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The crosspost that cannot duplicate itself
&lt;/h2&gt;

&lt;p&gt;A post opts in to dev.to with one line of front matter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;devto&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A script renders it, rewrites the components into portable Markdown and sends it with a &lt;code&gt;canonical_url&lt;/code&gt; pointing back here, so the copy never competes with the original in search results.&lt;/p&gt;

&lt;p&gt;The hard part was running it from CI, where there is no local state. Before sending anything the script asks dev.to what the account already holds and indexes it by canonical URL, so an article that exists is updated and never created twice, even on a fresh runner. The state file is only a cache plus a hash of the Markdown last sent, which is what stops an unchanged post being pushed back to the top of the feed on every deploy.&lt;/p&gt;

&lt;p&gt;Two things bit me there. dev.to renders Markdown with hard wrapping on, so my eighty column source lines each became a &lt;code&gt;&amp;lt;br&amp;gt;&lt;/code&gt;, which is why paragraphs are unwrapped before they are sent. And the API returns 429 well before its documented limits, so writes are spaced out and retried on the &lt;code&gt;Retry-After&lt;/code&gt; header.&lt;/p&gt;

&lt;p&gt;If you are reading this on dev.to, that is how it got here, and this is the second version of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Was it worth it
&lt;/h2&gt;

&lt;p&gt;For the pages, no. Hugo rendered them faster and no reader can tell the difference.&lt;/p&gt;

&lt;p&gt;For everything else, yes. The interactive parts are components with props instead of scripts hunting for selectors, the data files have a type, and the things that were held together by convention now fail at build time when I break them. That is what I actually bought, and it is worth thirteen seconds.&lt;/p&gt;

&lt;p&gt;What I would tell myself before starting: pin the URLs first and treat any change to one as a bug, keep the content and the data independent of whatever renders them, and do not trust a green build. Three of the bugs in this post produced no error at all.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://marcellomartini.tech/tools/" rel="noopener noreferrer"&gt;The rest of the toolbox&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This one first appeared on &lt;a href="https://marcellomartini.tech/posts/how-this-site-is-built/" rel="noopener noreferrer"&gt;my site&lt;/a&gt;, where I write about the systems I build and the tools I actually use — &lt;a href="https://marcellomartini.tech/posts/" rel="noopener noreferrer"&gt;the rest of the posts live here&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>astro</category>
      <category>webdev</category>
      <category>githubactions</category>
      <category>staticsite</category>
    </item>
    <item>
      <title>3 small tools that changed how I work every day</title>
      <dc:creator>martinimarcello00</dc:creator>
      <pubDate>Tue, 25 Aug 2026 15:38:09 +0000</pubDate>
      <link>https://dev.to/martinimarcello00/the-tools-i-actually-use-360k</link>
      <guid>https://dev.to/martinimarcello00/the-tools-i-actually-use-360k</guid>
      <description>&lt;p&gt;Most "my setup" posts are shopping lists. This one is not, because the tools below earned their place by surviving contact with a bad week: a deadline, a broken laptop, a paper due, an evening where I could not stop scrolling. Everything else got uninstalled.&lt;/p&gt;

&lt;p&gt;Three of them are worth telling properly.&lt;/p&gt;

&lt;h2&gt;
  
  
  chezmoi: my machine is a git clone
&lt;/h2&gt;

&lt;p&gt;I used to keep dotfiles in a repo with a &lt;code&gt;install.sh&lt;/code&gt; that symlinked things into place. It worked right up until my laptop and my home server needed &lt;em&gt;almost&lt;/em&gt; the same configuration: same aliases, different git email, different paths, and one of them absolutely must not carry a work SSH config.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.chezmoi.io/" rel="noopener noreferrer"&gt;chezmoi&lt;/a&gt; solves exactly that. The dotfiles live in one repository, the machine-specific bits are templates, and secrets are pulled from a password manager at apply time instead of sitting in git. Bootstrapping a new machine is now one command and a coffee.&lt;/p&gt;

&lt;p&gt;The unglamorous benefit is the one I care about: I stopped being afraid of reinstalling. When the setup is a &lt;code&gt;git clone&lt;/code&gt; away, wiping a machine is a chore rather than a decision.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://www.chezmoi.io/" rel="noopener noreferrer"&gt;chezmoi&lt;/a&gt;&lt;/strong&gt; — Dotfiles in git, secrets left out of it.&lt;/p&gt;

&lt;p&gt;Keeps my shell, editor and git configuration in one repository and templates the parts that differ per machine, so a work laptop and a home server share a setup without sharing their secrets. Bootstrapping a new machine is a single command instead of an afternoon of copy-paste.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Atuin: my shell history became searchable
&lt;/h2&gt;

&lt;p&gt;Every developer has a moment of typing &lt;code&gt;history | grep&lt;/code&gt; and hoping. Shell history is one of the most valuable datasets you generate, and by default it is a flat file that truncates, forgets which directory you were in, and vanishes when you open two terminals at once.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://atuin.sh/" rel="noopener noreferrer"&gt;Atuin&lt;/a&gt; replaces &lt;code&gt;Ctrl-R&lt;/code&gt; with a real search over a SQLite database of every command, recorded with its directory, its exit code and how long it took. It syncs end-to-end encrypted between machines, and you can run the sync server yourself if you would rather not trust anyone with it.&lt;/p&gt;

&lt;p&gt;What changed is subtle. I stopped writing throwaway shell scripts for things I do twice a year, because "the &lt;code&gt;ffmpeg&lt;/code&gt; command from that conference talk" is now a two-second lookup instead of an archaeology project.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://atuin.sh/" rel="noopener noreferrer"&gt;Atuin&lt;/a&gt;&lt;/strong&gt; — Shell history as a searchable database.&lt;/p&gt;

&lt;p&gt;Replaces Ctrl-R with a real search over every command I have ever run, recorded with its directory, exit code and duration. History syncs end-to-end encrypted between my laptop and my servers, and you can host the sync server yourself. The obscure ffmpeg invocation from eight months ago is now a two-second lookup.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  A brick, and the app behind it
&lt;/h2&gt;

&lt;p&gt;The one that is not software. I was properly hooked on my phone: in hand before my eyes were open, thumb scrolling through dinners and lectures. Willpower never fixed it.&lt;/p&gt;

&lt;p&gt;What fixed it was &lt;a href="https://marcellomartini.tech/brick/" rel="noopener noreferrer"&gt;a small orange square on my desk&lt;/a&gt;, a magnet and an NFC tag. Tap the phone against it and the apps I chose disappear. Tap again and they come back, but only if I walk to wherever I left the brick. That tiny bit of friction is the entire trick.&lt;/p&gt;

&lt;p&gt;Underneath it is &lt;a href="https://foqos.app/" rel="noopener noreferrer"&gt;Foqos&lt;/a&gt;, an open-source iOS app that ties Apple's Screen Time restrictions to an NFC tag. No account, no subscription, no streak to protect. Two weeks in, my screen time had halved and I had stopped missing it, which says more about the reflex than about the willpower.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://marcellomartini.tech/brick/" rel="noopener noreferrer"&gt;Focus Brick&lt;/a&gt;&lt;/strong&gt; — A magnet, an NFC tag, two taps.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://foqos.app/" rel="noopener noreferrer"&gt;Foqos&lt;/a&gt;&lt;/strong&gt; — The open-source app behind the brick.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The rest of the toolbox
&lt;/h2&gt;

&lt;p&gt;These three are the ones with a story. The rest of what I use every day lives on the toolbox page: editor, terminal, research and focus tools, each with a note on why it earned its place, filterable by category.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://marcellomartini.tech/tools/" rel="noopener noreferrer"&gt;See the full toolbox&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Nothing here is sponsored and nothing is permanent. If one of these stops earning its place, it comes off the list.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This one first appeared on &lt;a href="https://marcellomartini.tech/posts/tools-i-actually-use/" rel="noopener noreferrer"&gt;my site&lt;/a&gt;, where I write about the systems I build and the tools I actually use — &lt;a href="https://marcellomartini.tech/posts/" rel="noopener noreferrer"&gt;the rest of the posts live here&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>productivity</category>
      <category>devtools</category>
      <category>dotfiles</category>
      <category>selfhosted</category>
    </item>
    <item>
      <title>My entire terminal setup restores from one git clone</title>
      <dc:creator>martinimarcello00</dc:creator>
      <pubDate>Tue, 25 Aug 2026 15:27:05 +0000</pubDate>
      <link>https://dev.to/martinimarcello00/rebuilding-my-terminal-from-a-git-clone-293o</link>
      <guid>https://dev.to/martinimarcello00/rebuilding-my-terminal-from-a-git-clone-293o</guid>
      <description>&lt;p&gt;I spend more hours in a terminal than in any other window, and for years the configuration behind it lived nowhere: a &lt;code&gt;.zshrc&lt;/code&gt; I had edited so many times I no longer knew which line did what, a colour scheme picked in a preferences dialog, an SSH config that only existed on one laptop.&lt;/p&gt;

&lt;p&gt;What follows is what that turned into. Not a list of everything installed on my Mac, but the terminal I actually type in and the two-part trick that keeps it reproducible: every setting is a plain-text file, and every plain-text file is in a repository.&lt;/p&gt;

&lt;h2&gt;
  
  
  The terminal: Ghostty
&lt;/h2&gt;

&lt;p&gt;I moved off &lt;a href="https://iterm2.com/" rel="noopener noreferrer"&gt;iTerm2&lt;/a&gt; because I wanted a terminal that was fast and, more importantly, one whose entire appearance I could describe in text.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://ghostty.org/" rel="noopener noreferrer"&gt;Ghostty&lt;/a&gt;&lt;/strong&gt; — A fast terminal with no tabs of its own to manage.&lt;/p&gt;

&lt;p&gt;Native on macOS, GPU-accelerated, and quick enough that a long build log scrolls without the fan spinning up. The configuration is a single plain-text file with one setting per line, which means the whole appearance of my terminal is a handful of lines in my dotfiles rather than a screenshot of a preferences pane I would have to redo on the next machine.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Ghostty's &lt;a href="https://ghostty.org/docs/config" rel="noopener noreferrer"&gt;config&lt;/a&gt; is a single file, &lt;code&gt;~/.config/ghostty/config.ghostty&lt;/code&gt;, with &lt;code&gt;key = value&lt;/code&gt; on each line and &lt;code&gt;#&lt;/code&gt; for comments. That is the whole format:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="py"&gt;theme&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;dracula&lt;/span&gt;
&lt;span class="py"&gt;font-family&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"MesloLGS NF"&lt;/span&gt;
&lt;span class="py"&gt;font-size&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;14&lt;/span&gt;
&lt;span class="py"&gt;window-padding-x&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;8&lt;/span&gt;
&lt;span class="py"&gt;window-padding-y&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;6&lt;/span&gt;
&lt;span class="py"&gt;macos-option-as-alt&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;copy-on-select&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The interesting part is &lt;code&gt;theme&lt;/code&gt;. A Ghostty &lt;a href="https://ghostty.org/docs/config/reference#theme" rel="noopener noreferrer"&gt;theme&lt;/a&gt; is just another config file, so any file dropped in &lt;code&gt;~/.config/ghostty/themes/&lt;/code&gt; can be selected by name. Mine is &lt;a href="https://draculatheme.com/ghostty" rel="noopener noreferrer"&gt;Dracula&lt;/a&gt; and starts like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="py"&gt;palette&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;0=#21222c&lt;/span&gt;
&lt;span class="py"&gt;palette&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;1=#ff5555&lt;/span&gt;
&lt;span class="py"&gt;palette&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;2=#50fa7b&lt;/span&gt;
&lt;span class="py"&gt;background&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="c"&gt;#282a36
&lt;/span&gt;&lt;span class="s"&gt;foreground = #f8f8f2&lt;/span&gt;
&lt;span class="py"&gt;cursor-color&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="c"&gt;#f8f8f2
&lt;/span&gt;&lt;span class="s"&gt;selection-background = #44475a&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sixteen palette entries and a handful of colours: that is the difference between "my terminal looks right" and "my terminal looked right on the old machine". &lt;code&gt;cmd+shift+,&lt;/code&gt; reloads the config without restarting, so tuning it is a live loop rather than a quit-and-relaunch cycle.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shell: zsh, Oh My Zsh, Powerlevel10k
&lt;/h2&gt;

&lt;p&gt;macOS ships zsh, &lt;a href="https://ohmyz.sh/" rel="noopener noreferrer"&gt;Oh My Zsh&lt;/a&gt; handles plugin loading, and &lt;a href="https://github.com/romkatv/powerlevel10k" rel="noopener noreferrer"&gt;Powerlevel10k&lt;/a&gt; draws the prompt.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://ohmyz.sh/" rel="noopener noreferrer"&gt;Oh My Zsh&lt;/a&gt;&lt;/strong&gt; — A shell that tells me where I am.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Two lines do most of the work in my &lt;code&gt;.zshrc&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;ZSH_THEME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"powerlevel10k/powerlevel10k"&lt;/span&gt;
&lt;span class="nv"&gt;plugins&lt;/span&gt;&lt;span class="o"&gt;=(&lt;/span&gt;git zsh-syntax-highlighting z fzf-tab&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those four plugins are the ones that survived. &lt;code&gt;git&lt;/code&gt; for the aliases and the branch state, &lt;a href="https://github.com/zsh-users/zsh-syntax-highlighting" rel="noopener noreferrer"&gt;&lt;code&gt;zsh-syntax-highlighting&lt;/code&gt;&lt;/a&gt; because a command turning red before I press Enter has caught more typos than I would like to admit, &lt;a href="https://github.com/ohmyzsh/ohmyzsh/tree/master/plugins/z" rel="noopener noreferrer"&gt;&lt;code&gt;z&lt;/code&gt;&lt;/a&gt; to jump to a directory I visited last week by typing three letters of its name, and &lt;a href="https://github.com/Aloxaf/fzf-tab" rel="noopener noreferrer"&gt;&lt;code&gt;fzf-tab&lt;/code&gt;&lt;/a&gt; to hand tab-completion over to &lt;a href="https://github.com/junegunn/fzf" rel="noopener noreferrer"&gt;fzf&lt;/a&gt;, turning a wall of candidates into a fuzzy-searchable list.&lt;/p&gt;

&lt;p&gt;Powerlevel10k is configured with &lt;code&gt;p10k configure&lt;/code&gt; once, and the wizard writes &lt;code&gt;~/.p10k.zsh&lt;/code&gt; — which then goes into the dotfiles repo like everything else. Mine runs in &lt;code&gt;nerdfont-v3&lt;/code&gt; mode with the lean prompt, which means the prompt shows the git branch, the Python environment and the exit code of the last command. Most of my terminal mistakes used to be context mistakes: wrong branch, wrong virtualenv, wrong machine.&lt;/p&gt;

&lt;p&gt;One detail that is easy to miss: the p10k instant-prompt block has to stay at the very top of &lt;code&gt;.zshrc&lt;/code&gt;, above anything that might print or ask for input. It is the reason the prompt appears immediately instead of after the plugins finish loading.&lt;/p&gt;

&lt;h2&gt;
  
  
  History that is actually searchable
&lt;/h2&gt;

&lt;p&gt;The single biggest upgrade is not the prompt. It is replacing &lt;code&gt;Ctrl-R&lt;/code&gt; with something that remembers.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://atuin.sh/" rel="noopener noreferrer"&gt;Atuin&lt;/a&gt;&lt;/strong&gt; — Shell history as a searchable database.&lt;/p&gt;

&lt;p&gt;Replaces Ctrl-R with a real search over every command I have ever run, recorded with its directory, exit code and duration. History syncs end-to-end encrypted between my laptop and my servers, and you can host the sync server yourself. The obscure ffmpeg invocation from eight months ago is now a two-second lookup.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;One line in &lt;code&gt;.zshrc&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;eval&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;atuin init zsh&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After that, every command is recorded in SQLite along with its directory, exit code and duration, and &lt;code&gt;Ctrl-R&lt;/code&gt; becomes a real search across all of it — end-to-end encrypted between machines, with a &lt;a href="https://atuin.sh/docs/self-hosting" rel="noopener noreferrer"&gt;sync server&lt;/a&gt; you can host yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  The small stuff that adds up
&lt;/h2&gt;

&lt;p&gt;Aliases live in their own file rather than sprawling through &lt;code&gt;.zshrc&lt;/code&gt;, sourced at the end:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; ~/.zsh_aliases &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;source&lt;/span&gt; ~/.zsh_aliases
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Most of them are unremarkable — &lt;code&gt;gs&lt;/code&gt;, &lt;code&gt;ga&lt;/code&gt;, &lt;code&gt;gc&lt;/code&gt;, &lt;code&gt;gp&lt;/code&gt; for git, &lt;code&gt;grep --color=auto&lt;/code&gt;, &lt;code&gt;rm -i&lt;/code&gt; because I would rather be asked. The ones I actually notice are the navigation shortcuts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;alias&lt;/span&gt; .&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"cd .."&lt;/span&gt;
&lt;span class="nb"&gt;alias&lt;/span&gt; ...&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"cd ../../.."&lt;/span&gt;
&lt;span class="nb"&gt;alias&lt;/span&gt; ....&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"cd ../../../.."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two more lines worth stealing. &lt;a href="https://micro-editor.github.io/" rel="noopener noreferrer"&gt;&lt;code&gt;micro&lt;/code&gt;&lt;/a&gt; as the editor, because I want a terminal editor that behaves like every other text field when I am editing a commit message at 11pm:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;EDITOR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'micro'&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;VISUAL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'micro'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the binding that opens the current command line in that editor, which turns a long unreadable one-liner into something you can actually see:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;autoload &lt;span class="nt"&gt;-U&lt;/span&gt; edit-command-line
zle &lt;span class="nt"&gt;-N&lt;/span&gt; edit-command-line
bindkey &lt;span class="s1"&gt;'^x^e'&lt;/span&gt; edit-command-line
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Making all of it reproducible
&lt;/h2&gt;

&lt;p&gt;Everything above is text files. The part that makes it a setup rather than a collection is &lt;a href="https://www.chezmoi.io/" rel="noopener noreferrer"&gt;chezmoi&lt;/a&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://www.chezmoi.io/" rel="noopener noreferrer"&gt;chezmoi&lt;/a&gt;&lt;/strong&gt; — Dotfiles in git, secrets left out of it.&lt;/p&gt;

&lt;p&gt;Keeps my shell, editor and git configuration in one repository and templates the parts that differ per machine, so a work laptop and a home server share a setup without sharing their secrets. Bootstrapping a new machine is a single command instead of an afternoon of copy-paste.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;My dotfiles repository maps one-to-one onto my home directory — &lt;code&gt;dot_zshrc&lt;/code&gt; becomes &lt;code&gt;~/.zshrc&lt;/code&gt;, &lt;code&gt;dot_config/ghostty/themes/dracula&lt;/code&gt; becomes &lt;code&gt;~/.config/ghostty/themes/dracula&lt;/code&gt; — plus three &lt;a href="https://www.chezmoi.io/user-guide/use-scripts-to-perform-actions/" rel="noopener noreferrer"&gt;scripts&lt;/a&gt; that only run the first time chezmoi is applied on a machine:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;one installs &lt;a href="https://brew.sh/" rel="noopener noreferrer"&gt;Homebrew&lt;/a&gt; if missing and runs &lt;a href="https://docs.brew.sh/Brew-Bundle-and-Brewfile" rel="noopener noreferrer"&gt;&lt;code&gt;brew bundle&lt;/code&gt;&lt;/a&gt; against a &lt;code&gt;Brewfile&lt;/code&gt; that carries every formula, cask, App Store app and VS Code extension;&lt;/li&gt;
&lt;li&gt;one clones Oh My Zsh, Powerlevel10k, &lt;code&gt;fzf-tab&lt;/code&gt; and &lt;code&gt;zsh-syntax-highlighting&lt;/code&gt; into the right places;&lt;/li&gt;
&lt;li&gt;one applies the macOS defaults I would otherwise click through in System Settings — dark mode, Dock size, Finder path bar, key repeat.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The naming does the work: a file called &lt;code&gt;run_once_install-deps.sh&lt;/code&gt; runs once and chezmoi remembers its hash, so applying the dotfiles again does not reinstall Homebrew.&lt;/p&gt;

&lt;h3&gt;
  
  
  Secrets stay out of the repository
&lt;/h3&gt;

&lt;p&gt;The reason a dotfiles repo usually stops at "shell config" is SSH keys. chezmoi solves that with &lt;a href="https://www.chezmoi.io/user-guide/templating/" rel="noopener noreferrer"&gt;templates&lt;/a&gt;: a managed file can call out to a &lt;a href="https://www.chezmoi.io/user-guide/password-managers/bitwarden/" rel="noopener noreferrer"&gt;password manager&lt;/a&gt; at apply time. My signing key is a two-line file in the repo that contains no key at all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{{- (bitwarden "item" "ssh-git-signing").sshKey.privateKey }}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The public keys and &lt;code&gt;~/.ssh/config&lt;/code&gt; are committed as plain files, the private halves are rendered from &lt;a href="https://bitwarden.com/" rel="noopener noreferrer"&gt;Bitwarden&lt;/a&gt; at apply time and never touch git. The same mechanism fills in per-machine values — my &lt;code&gt;.chezmoi.toml.tmpl&lt;/code&gt; pulls a few cloud subscription IDs from a Bitwarden item so the helper scripts in &lt;code&gt;~/.local/bin&lt;/code&gt; know which subscription to talk to without those IDs living in a public repo.&lt;/p&gt;

&lt;p&gt;Once the keys are in place, git &lt;a href="https://docs.github.com/en/authentication/managing-commit-signature-verification/about-commit-signature-verification" rel="noopener noreferrer"&gt;signs commits with SSH&lt;/a&gt; rather than GPG, which is three lines and no keyring:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[user]&lt;/span&gt;
    &lt;span class="py"&gt;signingkey&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;~/.ssh/id_ed25519_signing.pub&lt;/span&gt;
&lt;span class="nn"&gt;[gpg]&lt;/span&gt;
    &lt;span class="py"&gt;format&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;ssh&lt;/span&gt;
&lt;span class="nn"&gt;[commit]&lt;/span&gt;
    &lt;span class="py"&gt;gpgsign&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  A new machine, start to finish
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. Homebrew&lt;/span&gt;
/bin/bash &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c"&gt;# 2. The three tools needed to bootstrap the rest&lt;/span&gt;
brew &lt;span class="nb"&gt;install &lt;/span&gt;chezmoi bitwarden-cli gh

&lt;span class="c"&gt;# 3. Auth: gh for the private repo, Bitwarden for the secrets&lt;/span&gt;
gh auth login &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; gh auth setup-git
bw login &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;BW_SESSION&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;bw unlock &lt;span class="nt"&gt;--raw&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c"&gt;# 4. Everything else&lt;/span&gt;
chezmoi init &lt;span class="nt"&gt;--apply&lt;/span&gt; gh:&amp;lt;user&amp;gt;/dotfiles
&lt;span class="nb"&gt;exec &lt;/span&gt;zsh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Step two is the whole bootstrap surface: chezmoi, the &lt;a href="https://bitwarden.com/help/cli/" rel="noopener noreferrer"&gt;Bitwarden CLI&lt;/a&gt; and the &lt;a href="https://cli.github.com/" rel="noopener noreferrer"&gt;GitHub CLI&lt;/a&gt;. Step four clones the repo, installs the Brewfile, applies the macOS defaults and renders every dotfile including the secrets. It takes as long as Homebrew takes.&lt;/p&gt;

&lt;p&gt;Day to day it stays out of the way: &lt;code&gt;chezmoi edit ~/.zshrc&lt;/code&gt; to change something, &lt;code&gt;chezmoi diff&lt;/code&gt; to see what would change, &lt;code&gt;chezmoi apply&lt;/code&gt; to commit to it, &lt;code&gt;chezmoi update&lt;/code&gt; on the other machine to pull it down.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that is still manual
&lt;/h2&gt;

&lt;p&gt;Powerlevel10k needs a &lt;a href="https://www.nerdfonts.com/" rel="noopener noreferrer"&gt;Nerd Font&lt;/a&gt;, and mine is not in the &lt;code&gt;Brewfile&lt;/code&gt; — I installed &lt;a href="https://github.com/romkatv/powerlevel10k/blob/master/font.md" rel="noopener noreferrer"&gt;MesloLGS NF&lt;/a&gt; by hand at some point and never went back. So on a fresh machine the prompt comes up with boxes where the icons should be until I fix it, which is exactly the kind of thing this whole setup is supposed to prevent. It is one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;cask&lt;/span&gt; &lt;span class="s2"&gt;"font-meslo-lg-nerd-font"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Which is a decent summary of how this works in practice. The setup is not finished and never will be; it just gets one line less manual every time something annoys me enough.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://marcellomartini.tech/tools/" rel="noopener noreferrer"&gt;See the full toolbox&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This one first appeared on &lt;a href="https://marcellomartini.tech/posts/terminal-from-a-git-clone/" rel="noopener noreferrer"&gt;my site&lt;/a&gt;, where I write about the systems I build and the tools I actually use — &lt;a href="https://marcellomartini.tech/posts/" rel="noopener noreferrer"&gt;the rest of the posts live here&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>terminal</category>
      <category>zsh</category>
      <category>dotfiles</category>
      <category>productivity</category>
    </item>
  </channel>
</rss>
