<?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: Qasim Parray</title>
    <description>The latest articles on DEV Community by Qasim Parray (@abyzgenic).</description>
    <link>https://dev.to/abyzgenic</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%2F4120845%2Fdf746bdb-4b77-4db2-8b04-ed1d2da71842.png</url>
      <title>DEV Community: Qasim Parray</title>
      <link>https://dev.to/abyzgenic</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/abyzgenic"/>
    <language>en</language>
    <item>
      <title>PHP 8.5.12 RCE Patch: The Checklist I Run on Every Server</title>
      <dc:creator>Qasim Parray</dc:creator>
      <pubDate>Sat, 24 Oct 2026 16:39:00 +0000</pubDate>
      <link>https://dev.to/abyzgenic/php-8512-rce-patch-the-checklist-i-run-on-every-server-4bbc</link>
      <guid>https://dev.to/abyzgenic/php-8512-rce-patch-the-checklist-i-run-on-every-server-4bbc</guid>
      <description>&lt;p&gt;I was halfway through a coffee yesterday evening when the PHP security advisory landed in my feed, and the coffee did not survive the next ten minutes. Two critical remote code execution bugs, unauthenticated, and the PHP team's own wording was "update immediately". No "consider upgrading when convenient". That phrasing is rare enough that I stopped what I was doing.&lt;/p&gt;

&lt;p&gt;Short version for the impatient: update to PHP 8.5.12, 8.4.27, 8.3.36 or 8.2.35, restart your workers, and confirm with &lt;code&gt;php -v&lt;/code&gt;. Everything below is the checklist I'm using, the stopgap settings if you can't patch yet, and a few places where I think people will get tripped up.&lt;/p&gt;

&lt;p&gt;I'm not a security researcher. I'm a developer who deploys PHP apps for clients and runs a few boxes of my own, so this is written from the "I have to patch this today and prove it" side of the fence. Read the primary sources too. I link them as I go.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the advisory actually says
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://www.php.net/index.php#2026-10-21-5" rel="noopener noreferrer"&gt;official advisory on php.net&lt;/a&gt; names two bugs. The first is CVE-2026-109618, which the team describes as RCE via out-of-memory corruption. When PHP runs out of memory at a bad moment, it can be interrupted while internal request data is only half initialized, and the cleanup code then trusts that invalid data. The second is CVE-2026-109619, RCE via stale global state. Some request-owned internal state survives after its memory has been freed, and the next request on the same worker can pick it up before normal initialization resets it.&lt;/p&gt;

&lt;p&gt;Both are unauthenticated. The team says the risk is highest for mod_php, where they have a demonstrated exploit that fires before any application code runs. That last part matters for how you think about this. Your framework's middleware, your WAF rules written against your routes, your login page: none of it is in the path. The bug lives below your code.&lt;/p&gt;

&lt;p&gt;The affected list is wide. PHP-FPM, Apache with mod_php, php-cgi, FrankenPHP and LiteSpeed are all named. If your PHP serves web requests, you're in scope. &lt;a href="https://laravel-news.com/php-critical-rce-security-releases" rel="noopener noreferrer"&gt;Laravel News covered the release&lt;/a&gt; with the platform-by-platform status, which is the quickest way to see where Forge, Vapor, Cloud and Envoyer stand.&lt;/p&gt;

&lt;p&gt;I'm deliberately not going to describe exploitation details. I don't know them beyond what the advisory says, and guessing would be worse than silence.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which version you're on decides the next hour
&lt;/h2&gt;

&lt;p&gt;Here's where I'd sort yourself, because the work is different for each bucket.&lt;/p&gt;

&lt;p&gt;If you're on 8.2 through 8.5, the fix is a normal package or image update. The &lt;a href="https://www.php.net/ChangeLog-8.php" rel="noopener noreferrer"&gt;8.5.12 changelog&lt;/a&gt; lists the fix for both advisories under Core as "Avoid state corruption via bailout", alongside a long pile of other fixes, including use-after-free bugs in DOM and BZ2 and a file descriptor leak in the built-in server. So even if the RCE bugs somehow didn't apply to you, this is not a release to skip.&lt;/p&gt;

&lt;p&gt;If you're on 8.1 or older, the PHP project no longer ships updates for you. The security team published minimal source backports for 7.4, 8.0 and 8.1 for people building from source, which is generous, but I wouldn't treat it as a plan. If you have a distribution or LTS vendor, ask them for their backport. If you don't, this is the week the "we'll upgrade PHP next quarter" ticket becomes this week's ticket. I wrote about how I plan those upgrade windows in &lt;a href="https://abrarqasim.com/blog/laravel-13-release-date-support-window-i-plan-client-upgrades-around" rel="noopener noreferrer"&gt;my Laravel 13 support window post&lt;/a&gt;, and the short version is that waiting for a security release to force the issue is the expensive way to do it.&lt;/p&gt;

&lt;p&gt;If you use the volunteer-maintained Remi repo, read the notice carefully. Per Laravel News, it won't have updates today, and the PHP team recommends distribution packages or building from source in the meantime. That one catches people because "apt upgrade says nothing to do" feels like good news.&lt;/p&gt;

&lt;h2&gt;
  
  
  The checklist I'm using
&lt;/h2&gt;

&lt;p&gt;Order matters here, so I'm keeping it boring and sequential.&lt;/p&gt;

&lt;p&gt;First, find out what you actually run. Not what you think you run. On each server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;php &lt;span class="nt"&gt;-v&lt;/span&gt;
php-fpm8.4 &lt;span class="nt"&gt;-v&lt;/span&gt; 2&amp;gt;/dev/null
apachectl &lt;span class="nt"&gt;-M&lt;/span&gt; 2&amp;gt;/dev/null | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; php
ps aux | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'php-fpm|php-cgi|frankenphp|lsphp'&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="nb"&gt;grep&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The CLI version and the FPM version can differ, and I've been bitten by that before. A box can show a patched &lt;code&gt;php -v&lt;/code&gt; while the FPM pool still runs the old binary because nobody restarted it. Which brings me to the second step: patching the package does not patch the running process. Workers that were started before the update keep running the old code until they restart.&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;sudo &lt;/span&gt;apt update &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--only-upgrade&lt;/span&gt; php8.4-fpm php8.4-cli
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl restart php8.4-fpm
&lt;span class="c"&gt;# containers: pull the new base image, rebuild, redeploy&lt;/span&gt;
docker compose pull &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--build&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Third, verify against the running thing, not the package manager. For FPM, I check the master process's binary and start time. For containers, I check the image digest of what's actually up.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;php-fpm8.4 &lt;span class="nt"&gt;-v&lt;/span&gt;
systemctl show php8.4-fpm &lt;span class="nt"&gt;--property&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;ActiveEnterTimestamp
docker compose &lt;span class="nb"&gt;exec &lt;/span&gt;app php &lt;span class="nt"&gt;-v&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fourth, remember the platforms that don't patch you automatically. According to Laravel's status notes, Cloud was patched in place, but Forge servers need you to click Update PHP on each one, and Vapor needs a redeploy (a new deployment, not just a redeploy, if you're on a custom or Docker image runtime). Laravel also says Forge has no patch for PHP 8.0 or 8.1 and that Ubuntu 20.04 or older servers can't get the update at all. For those you need a new server on a supported Ubuntu release. I'd power the old box down until you've moved.&lt;/p&gt;

&lt;p&gt;Fifth, patch Node too if the same box runs it. The Laravel News piece mentions Node.js security releases shipped alongside, and Forge doesn't update Node for you. I haven't read the Node advisory closely enough to say more, so go read it yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you can't patch today
&lt;/h2&gt;

&lt;p&gt;The advisory has a section for this, and I'd read it on php.net rather than from my summary, because the details matter. The PHP team is blunt that these measures reduce exposure to specific attack paths and do not make an installation safe. Treat them as a seatbelt on the way to the update, not a substitute.&lt;/p&gt;

&lt;p&gt;For the OOM bug, the advisory suggests setting &lt;code&gt;memory_limit=-1&lt;/code&gt; in a place applications can't override it, and a low &lt;code&gt;post_max_size&lt;/code&gt;. The counterintuitive part is the unlimited memory limit. I'd have bet money the safer move was a tighter limit. The reasoning is that a finite PHP memory limit is a controllable way to trigger the out-of-memory failure, so removing it takes away the lever. They also say an underlying allocation failure can still reach the vulnerable path, so this is partial at best. And they warn against using &lt;code&gt;RLIMIT_AS&lt;/code&gt; or &lt;code&gt;RLIMIT_DATA&lt;/code&gt; as a substitute, because those can themselves cause the vulnerable allocation failure.&lt;/p&gt;

&lt;p&gt;Here's what that looks like in an FPM pool, where &lt;code&gt;php_admin_value&lt;/code&gt; can't be overridden by app code:&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="c"&gt;; /etc/php/8.4/fpm/pool.d/www.conf
&lt;/span&gt;&lt;span class="na"&gt;php_admin_value&lt;/span&gt;&lt;span class="nn"&gt;[memory_limit]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="na"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="na"&gt;-1&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="na"&gt;php_admin_value&lt;/span&gt;&lt;span class="nn"&gt;[post_max_size]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="na"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="na"&gt;8M&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="na"&gt;php_admin_value&lt;/span&gt;&lt;span class="nn"&gt;[upload_max_filesize]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="na"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="na"&gt;6M&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="na"&gt;php_admin_value&lt;/span&gt;&lt;span class="nn"&gt;[session.upload_progress.enabled]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="na"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="na"&gt;0&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The 8M figure is the advisory's suggestion for apps without file uploads, and 32M for apps that need uploads, with &lt;code&gt;upload_max_filesize&lt;/code&gt; kept below &lt;code&gt;post_max_size&lt;/code&gt;. Adjust to your app.&lt;/p&gt;

&lt;p&gt;For the stale global state bug, the advisory says to set &lt;code&gt;session.upload_progress.enabled=0&lt;/code&gt; and restart every worker, using php.ini, an FPM pool or server config that applies before request parsing. It specifically says not to rely on &lt;code&gt;ini_set()&lt;/code&gt; or &lt;code&gt;.user.ini&lt;/code&gt;, because they apply too late. There's also a PHP 8.5 item about &lt;code&gt;fatal_error_backtraces=0&lt;/code&gt;. Read the full text, since I'm only summarizing part of it.&lt;/p&gt;

&lt;p&gt;One thing I'd add from my own habits, and this is opinion rather than advisory: if unlimited memory makes you nervous on a small VPS, pair it with a cgroup or container memory limit that kills only the worker. The advisory itself recommends a container or cgroup limit as an availability boundary. On my own boxes that's a systemd drop-in with &lt;code&gt;MemoryMax&lt;/code&gt; on the FPM unit, which I cover in my &lt;a href="https://abrarqasim.com/blog/linux-server-hardening-small-vps-baseline-i-run-first" rel="noopener noreferrer"&gt;VPS hardening baseline&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'm still unsure about
&lt;/h2&gt;

&lt;p&gt;I don't know yet how many installs are being probed. I haven't seen exploit traffic on my own servers, and absence of evidence on a handful of boxes means nothing. I also can't tell you whether the interim settings hold up, because the advisory itself hedges on them.&lt;/p&gt;

&lt;p&gt;The other open question is logs. If an attacker got in before you patched, a clean &lt;code&gt;php -v&lt;/code&gt; tells you nothing about the past. I'm checking web server access logs for oddly large or oddly shaped requests since the 21st and looking for unexpected files and processes under the web user. If you run anything with client data on it, assume you may want a proper incident review rather than my grep one-liners. For client work I'd rather write that email now than later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do these four things before you close the laptop
&lt;/h2&gt;

&lt;p&gt;Check the PHP version on every server and container you own, then restart FPM or Apache and recheck against the running process. Move anything on PHP 8.1 or older onto a supported release, or get a written backport commitment from your vendor. If a box can't be patched tonight, apply the advisory's interim settings and put a calendar block on the patch. And if you maintain client sites, send each client a two-line note saying what you patched and when, because they will hear about this from somewhere else and it's better coming from you. If you want help with a patch sweep or the upgrade plan behind it, my work and contact details are at &lt;a href="https://abrarqasim.com" rel="noopener noreferrer"&gt;abrarqasim.com&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://abrarqasim.com/blog/php-8-5-12-security-update-rce-patch-checklist-php-fpm-mod-php/" rel="noopener noreferrer"&gt;abrarqasim.com&lt;/a&gt;. I write there about React, PHP, Rust, Go and the AI tooling around them.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>php</category>
      <category>security</category>
      <category>phpfpm</category>
      <category>laravel</category>
    </item>
    <item>
      <title>TypeScript 7.1 Beta: Import Attributes in Ambient Modules</title>
      <dc:creator>Qasim Parray</dc:creator>
      <pubDate>Fri, 23 Oct 2026 16:38:59 +0000</pubDate>
      <link>https://dev.to/abyzgenic/typescript-71-beta-import-attributes-in-ambient-modules-285o</link>
      <guid>https://dev.to/abyzgenic/typescript-71-beta-import-attributes-in-ambient-modules-285o</guid>
      <description>&lt;p&gt;I have a file called &lt;code&gt;globals.d.ts&lt;/code&gt; in more projects than I'd like to admit. It started life as three lines for &lt;code&gt;*.css&lt;/code&gt; imports, and over time it picked up &lt;code&gt;*.svg&lt;/code&gt;, &lt;code&gt;*.md&lt;/code&gt;, &lt;code&gt;*.txt&lt;/code&gt; and one cursed &lt;code&gt;*.sql&lt;/code&gt; entry that I'm fairly sure nobody has touched since it was written. Every new repo, I copy it over without reading it. It works, so I never look at it.&lt;/p&gt;

&lt;p&gt;Then I read the &lt;a href="https://devblogs.microsoft.com/typescript/announcing-typescript-7-1-beta/" rel="noopener noreferrer"&gt;TypeScript 7.1 beta announcement&lt;/a&gt; and the first feature on the list made me open that file and wince. Ambient module declarations can now carry import attributes. That sounds like a tiny change. It isn't, because it quietly fixes a lie that wildcard declarations have been telling for years.&lt;/p&gt;

&lt;p&gt;Short version for the impatient: if your project imports CSS, text or binary files, 7.1 lets you declare the type per import attribute instead of per file extension, and the compiler will complain when you forget the attribute. I haven't moved a client project onto the beta yet, so this post is a read of the announcement plus the checklist I'm going to run, not a victory lap.&lt;/p&gt;

&lt;h2&gt;
  
  
  The declaration file I keep copy-pasting
&lt;/h2&gt;

&lt;p&gt;Here's roughly what lives in my &lt;code&gt;globals.d.ts&lt;/code&gt;, and probably in yours:&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="kr"&gt;declare&lt;/span&gt; &lt;span class="kr"&gt;module&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;*.css&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CSSStyleSheet&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kr"&gt;declare&lt;/span&gt; &lt;span class="kr"&gt;module&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;*.md&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;content&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="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kr"&gt;declare&lt;/span&gt; &lt;span class="kr"&gt;module&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;*.txt&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;content&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="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nx"&gt;content&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;Look at the second and third blocks. They are identical except for the extension. Every time I add a file type that should come in as a string, I paste another block. The declaration is keyed on the filename, but the thing that decides what you get at runtime is no longer the filename.&lt;/p&gt;

&lt;p&gt;That's the part I had stopped thinking about. Import attributes, the &lt;code&gt;with { type: "text" }&lt;/code&gt; bit after an import, exist because a runtime can't safely guess what a file is from its extension. The announcement uses the browser as the example: a &lt;code&gt;.json&lt;/code&gt; file can't just be assumed to be JSON, so you say so in the import. The &lt;a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import/with" rel="noopener noreferrer"&gt;MDN reference for the &lt;code&gt;with&lt;/code&gt; clause&lt;/a&gt; covers the JSON case, and the text, bytes and CSS variants are in the proposals the announcement links to.&lt;/p&gt;

&lt;p&gt;So we have a runtime that dispatches on attributes and a type declaration that dispatches on extensions. They agree when everything goes well. They disagree the moment somebody forgets the attribute, and the old declaration still hands back a perfectly typed &lt;code&gt;string&lt;/code&gt; for an import that will blow up at runtime or give you something else entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the 7.1 beta actually adds
&lt;/h2&gt;

&lt;p&gt;The new syntax puts the attribute on the module declaration itself. This is straight from the announcement:&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="kr"&gt;declare&lt;/span&gt; &lt;span class="kr"&gt;module&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;*&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="kd"&gt;with&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;css&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CSSStyleSheet&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kr"&gt;declare&lt;/span&gt; &lt;span class="kr"&gt;module&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;*&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="kd"&gt;with&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="na"&gt;content&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="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kr"&gt;declare&lt;/span&gt; &lt;span class="kr"&gt;module&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;*&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="kd"&gt;with&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bytes&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nx"&gt;content&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;Three declarations replace my pile. The pattern is a bare &lt;code&gt;*&lt;/code&gt;, and the type depends on the attribute. After that, a file import has to spell the attribute out:&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;import&lt;/span&gt; &lt;span class="nx"&gt;readme&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;../README.md&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="kd"&gt;with&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;   &lt;span class="c1"&gt;// string&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;theme&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./theme.css&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="kd"&gt;with&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;css&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;      &lt;span class="c1"&gt;// CSSStyleSheet&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;photo&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./photo.png&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="kd"&gt;with&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bytes&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;    &lt;span class="c1"&gt;// Uint8Array&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Drop the &lt;code&gt;with&lt;/code&gt; clause and the import no longer matches any ambient declaration, so you get a compile error instead of a silently wrong type. That is the entire point. A forgotten attribute becomes something the compiler catches in your editor rather than something a user catches in the browser console.&lt;/p&gt;

&lt;p&gt;Two details from the announcement are easy to skim past. First, imports matching these declarations are not checked against files on disk. If you typo the path, TypeScript won't tell you; your bundler or runtime will. That's the same as today's wildcard modules, but it's worth remembering that a &lt;code&gt;*&lt;/code&gt; pattern means "anything goes" for the path half of the import.&lt;/p&gt;

&lt;p&gt;Second, the team says these declarations are not shipped in the built-in libraries, and they recommend that libraries don't publish them either. You declare them in your own app. The reasoning is that &lt;code&gt;lib.dom.d.ts&lt;/code&gt; and &lt;code&gt;@types/node&lt;/code&gt; could each want to define their own, and a library that bakes one in will eventually collide with a built-in. I agree with that, and it also means my copy-the-file habit survives in a slightly smaller form. I'll still have a &lt;code&gt;globals.d.ts&lt;/code&gt;. It will just be shorter and more honest.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the extension-keyed version was always slightly wrong
&lt;/h2&gt;

&lt;p&gt;I want to be fair to the old pattern. For most apps, &lt;code&gt;*.css&lt;/code&gt; plus a bundler config worked fine for years, and I'm not going to pretend it was a disaster. Where it falls over is when two environments disagree about a file type.&lt;/p&gt;

&lt;p&gt;The announcement gives a concrete case. A bundler may treat &lt;code&gt;.css&lt;/code&gt; imports as CSS Modules by default, handing you an object of class names. The browser's native behaviour with &lt;code&gt;type: "css"&lt;/code&gt; is to hand you a &lt;code&gt;CSSStyleSheet&lt;/code&gt; you can push into &lt;code&gt;document.adoptedStyleSheets&lt;/code&gt;. Same extension, two different return types, selected by whether the attribute is present. A declaration keyed on &lt;code&gt;*.css&lt;/code&gt; can only describe one of them. With the attribute in the declaration, you can have both: a CSS Modules declaration for plain &lt;code&gt;.css&lt;/code&gt; imports and an attribute-qualified one for the stylesheet object.&lt;/p&gt;

&lt;p&gt;I don't use that split often. Most of my sites take one approach and stick to it. But picture a project where a component library expects constructable stylesheets while the app shell uses CSS Modules. That's the setup where an extension-keyed declaration forces you to lie about one side. If that sounds like your codebase, this feature solves a real problem rather than a cosmetic one.&lt;/p&gt;

&lt;p&gt;There's also the maintenance angle. With extension patterns, every new text-like format is another block. With attributes, a new &lt;code&gt;.graphql&lt;/code&gt; or &lt;code&gt;.sql&lt;/code&gt; file that should arrive as a string needs no new declaration at all, only the right &lt;code&gt;with { type: "text" }&lt;/code&gt; at the import site. Fewer declarations means fewer places for a stale one to hide, and I have at least one stale one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part I'd check before trusting any of it
&lt;/h2&gt;

&lt;p&gt;Beta software gets a checklist, not a leap of faith. This is the order I'm going to follow on a throwaway branch of a real project, not a toy repo.&lt;/p&gt;

&lt;p&gt;Install it next to the stable compiler so I can compare: &lt;code&gt;npm install -D typescript@beta&lt;/code&gt;. Then run the type check once on the old declarations to see that nothing changed. If the beta flags unrelated errors, that tells me whether any problems come from my config or from the release.&lt;/p&gt;

&lt;p&gt;Next, replace the wildcard blocks with the three attribute-qualified ones and see what breaks. I expect a pile of errors on every import that lacks &lt;code&gt;with&lt;/code&gt;. That list is the useful output. Each error is a place where the old setup was returning a type for an import that the runtime might treat differently.&lt;/p&gt;

&lt;p&gt;Then check the tools around the compiler, because the compiler is the easy part. Does my bundler accept &lt;code&gt;with { type: "text" }&lt;/code&gt; and emit the right thing? Does the test runner? Does the editor's language server pick up the new declaration form, or does it show red squiggles on valid code until it's updated? Vite, esbuild and Node each have their own timeline for text and bytes imports, and TypeScript accepting the syntax tells me nothing about whether my build does. I've been bitten before by a green type check on code that the bundler later refused, so this step gets the most time.&lt;/p&gt;

&lt;p&gt;Finally, grep the repo for every &lt;code&gt;declare module "*.&lt;/code&gt; line and write down which ones are for files that only one environment ever consumes. Those can stay as they are. Converting everything because I can would be the kind of tidy-up that creates a Friday-evening incident.&lt;/p&gt;

&lt;p&gt;If your project has a &lt;code&gt;declare module "*.svg"&lt;/code&gt; for React components via a bundler plugin, leave it alone. SVG-as-component is a bundler transform, not an import attribute, and the new syntax doesn't model it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Source phase imports and the es2026 target
&lt;/h2&gt;

&lt;p&gt;The beta has two more items I'd keep an eye on without rushing. Source phase imports add &lt;code&gt;import source&lt;/code&gt;, which gives you a module as an inert, parsed-but-not-run object. For WebAssembly that object is a &lt;code&gt;WebAssembly.Module&lt;/code&gt;, so you can write &lt;code&gt;import source Fast from "./fast.wasm"&lt;/code&gt; and pass it straight to &lt;code&gt;WebAssembly.instantiate&lt;/code&gt;. The announcement says Node already supports Wasm source imports in recent versions. For JavaScript files you can't do much with it yet, and many tools don't support it at all. Unless you load Wasm by hand, skip it for now.&lt;/p&gt;

&lt;p&gt;The other is &lt;code&gt;--target es2026&lt;/code&gt; and &lt;code&gt;--lib es2026&lt;/code&gt; as stable options. The list in the announcement includes &lt;code&gt;Array.fromAsync()&lt;/code&gt;, &lt;code&gt;Error.isError()&lt;/code&gt;, &lt;code&gt;Math.sumPrecise()&lt;/code&gt;, &lt;code&gt;Uint8Array.fromBase64()&lt;/code&gt; and &lt;code&gt;toHex()&lt;/code&gt;, plus &lt;code&gt;getOrInsert()&lt;/code&gt; and &lt;code&gt;getOrInsertComputed()&lt;/code&gt; on &lt;code&gt;Map&lt;/code&gt; and &lt;code&gt;WeakMap&lt;/code&gt;. That last pair is the one I'd use immediately. I've written the "get, check for undefined, set, return" dance on a &lt;code&gt;Map&lt;/code&gt; more times than I can count, and a built-in replacement removes about four lines each time. Check your runtime's support before bumping the target, since the lib types only promise the declarations exist, not that your Node or browser version ships the methods.&lt;/p&gt;

&lt;p&gt;If you read the TypeScript 7 changes with the Go-based compiler in mind, the new programmatic API entry points (&lt;code&gt;typescript/sync&lt;/code&gt;, &lt;code&gt;typescript/async&lt;/code&gt; and friends) matter for tool authors more than for app developers. I covered a related tooling shift in my post on &lt;a href="https://abrarqasim.com/blog/vite-plus-1-0-oxlint-tsgolint-the-benchmarks-i-will-run-before-switching" rel="noopener noreferrer"&gt;Vite+ 1.0, oxlint and tsgolint&lt;/a&gt;, and the same rule applies here: measure on your own repo before changing anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do this week
&lt;/h2&gt;

&lt;p&gt;Open your &lt;code&gt;globals.d.ts&lt;/code&gt; and count the &lt;code&gt;declare module "*.&lt;/code&gt; blocks that return the same type under different extensions. If that number is two or more, the attribute-qualified form will probably shrink your file. Install &lt;code&gt;typescript@beta&lt;/code&gt; on a branch, swap those blocks, and read the error list before you decide anything. If you want to see how I approach this kind of upgrade on client work, my &lt;a href="https://abrarqasim.com" rel="noopener noreferrer"&gt;portfolio&lt;/a&gt; has the write-ups.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://abrarqasim.com/blog/typescript-7-1-beta-import-attributes-ambient-modules-what-i-test-first/" rel="noopener noreferrer"&gt;abrarqasim.com&lt;/a&gt;. I write there about React, PHP, Rust, Go and the AI tooling around them.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>typescript7</category>
      <category>importattributes</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Laravel DB::olap() on PlanetScale: When I'd Still Chunk</title>
      <dc:creator>Qasim Parray</dc:creator>
      <pubDate>Thu, 22 Oct 2026 16:38:55 +0000</pubDate>
      <link>https://dev.to/abyzgenic/laravel-dbolap-on-planetscale-when-id-still-chunk-14hd</link>
      <guid>https://dev.to/abyzgenic/laravel-dbolap-on-planetscale-when-id-still-chunk-14hd</guid>
      <description>&lt;p&gt;I read Paul Redmond's write-up of the new &lt;code&gt;DB::olap()&lt;/code&gt; method on &lt;a href="https://laravel-news.com/laravel-planetscale-olap" rel="noopener noreferrer"&gt;Laravel News&lt;/a&gt; this morning, and my first reaction was a mix of "nice, small, useful" and "I want to be suspicious of this before I trust it". I haven't run it against a PlanetScale branch yet. So everything below is a read of the article, the pull request and PlanetScale's docs, plus some opinions about where I'd get burned. Not a benchmark.&lt;/p&gt;

&lt;p&gt;Short version for the impatient: &lt;code&gt;DB::olap()&lt;/code&gt; is a tidy way to lift PlanetScale's per-query row cap for one callback. It is good for occasional exports and backfills. It is a bad default for anything a user is waiting on, and it has one trap that code review will miss.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the limit actually is
&lt;/h2&gt;

&lt;p&gt;PlanetScale runs MySQL on Vitess, and by default every query runs in an OLTP workload with guard rails. According to the &lt;a href="https://planetscale.com/docs/vitess/troubleshooting/planetscale-system-limits" rel="noopener noreferrer"&gt;PlanetScale system limits page&lt;/a&gt;, a query there can return, update or delete at most 100,000 rows, and a result set tops out at 64 MiB. Those numbers exist so one lazy report can't starve everyone else on the cluster.&lt;/p&gt;

&lt;p&gt;The part that bites Laravel developers is not the limit itself. It's that nothing on your laptop enforces it. A local MySQL container happily returns two million rows, your export job passes its test with a seeded table of fifty rows, and the first time it meets a real customer with three years of orders it falls over in production. The code is identical. The database is not.&lt;/p&gt;

&lt;p&gt;That's an old category of bug, and I've never found a cure other than running against something shaped like production. I wrote about a different flavour of the same problem, &lt;a href="https://abrarqasim.com/blog/laravel-postgres-unindexed-foreign-keys-vacuum-lint-in-ci" rel="noopener noreferrer"&gt;indexes your tests never notice are missing&lt;/a&gt;, and the lesson there applies here: a green test suite on a small database proves very little about a big one.&lt;/p&gt;

&lt;p&gt;Vitess has a second workload, OLAP, that drops those limits. Until now, using it from Laravel meant reaching for a raw statement.&lt;/p&gt;

&lt;h2&gt;
  
  
  The old way and the new way
&lt;/h2&gt;

&lt;p&gt;Before Laravel 13.36, running one query in OLAP mode looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="no"&gt;DB&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;statement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"set workload = 'olap'"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nv"&gt;$rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Order&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'created_at'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&amp;gt;='&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;subYears&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="no"&gt;DB&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;statement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"set workload = 'oltp'"&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 things are wrong with that, and the article spells both out. The raw statement runs on the write connection, so if you have read replicas the &lt;code&gt;select&lt;/code&gt; goes to a read connection that never left OLTP mode. You set the flag and the query ignored it. And because the workload is a session setting, a missing &lt;code&gt;finally&lt;/code&gt; leaves the connection in OLAP for whatever runs next, which in a queue worker or an Octane process is somebody else's job.&lt;/p&gt;

&lt;p&gt;The new method, added in &lt;a href="https://github.com/laravel/framework/pull/61932" rel="noopener noreferrer"&gt;this framework pull request&lt;/a&gt;, wraps all of that:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$revenue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;DB&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;olap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Order&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;selectRaw&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"customer_id, date_format(created_at, '%Y-%m') as month, sum(total) as revenue"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'created_at'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&amp;gt;='&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;subYears&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;groupBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'customer_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'month'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&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;It sets the workload on both the write and the read connection, runs your callback, and flips back to OLTP in a &lt;code&gt;finally&lt;/code&gt; block. Whatever the callback returns, you get back. If the database isn't Vitess, it just calls the callback and does nothing, which means the same code runs on local MySQL and in CI without a branch.&lt;/p&gt;

&lt;p&gt;I like that last decision a lot. A helper that only works against one vendor would force an &lt;code&gt;if&lt;/code&gt; at every call site, and people would forget it. A no-op elsewhere is the right default.&lt;/p&gt;

&lt;p&gt;One catch: the method lives on the MySQL connection class. If your default connection is PostgreSQL or SQLite, &lt;code&gt;DB::olap()&lt;/code&gt; throws a &lt;code&gt;BadMethodCallException&lt;/code&gt;. That matters if you run SQLite in tests and MySQL in production. You'd get a failure in the one place you didn't want one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trap: lazy results leave the callback
&lt;/h2&gt;

&lt;p&gt;This is the part I'd flag in every pull request that uses the method.&lt;/p&gt;

&lt;p&gt;The workload flips back the moment the callback returns. So this looks right and isn't:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$orders&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;DB&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;olap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Order&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

&lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$orders&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// the query actually runs here, back in OLTP mode&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;cursor()&lt;/code&gt;, &lt;code&gt;lazy()&lt;/code&gt; and &lt;code&gt;lazyById()&lt;/code&gt; return a &lt;code&gt;LazyCollection&lt;/code&gt;, and a lazy collection doesn't touch the database until something iterates it. The arrow function returns the collection, &lt;code&gt;olap()&lt;/code&gt; resets the workload, and then your &lt;code&gt;foreach&lt;/code&gt; triggers the real query under the old limits. The code reads as correct. It fails, or worse, silently runs under different rules than you think.&lt;/p&gt;

&lt;p&gt;The fix is boring: iterate inside the callback, or call &lt;code&gt;get()&lt;/code&gt; there. The article's queued export does the first. But "boring fix" and "easy to notice in review" are different things. An arrow function returning a query builder chain looks identical whether the last call is &lt;code&gt;get()&lt;/code&gt; or &lt;code&gt;cursor()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;There's a second, quieter limit. OLAP removes PlanetScale's 64 MiB result cap, but it doesn't remove PHP's &lt;code&gt;memory_limit&lt;/code&gt;. A &lt;code&gt;get()&lt;/code&gt; over three years of orders moves the ceiling from the database to your worker's memory. I'd pair &lt;code&gt;olap()&lt;/code&gt; with &lt;code&gt;lazyById()&lt;/code&gt; and a loop that writes rows out as it goes, and I'd treat a bare &lt;code&gt;get()&lt;/code&gt; over a big table as a smell even when the database allows it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where I would still chunk
&lt;/h2&gt;

&lt;p&gt;PlanetScale's own docs recommend against OLAP for regular application queries. The article summarises that guidance: the OLTP limits stop long-running queries from using up the database's resources, and large reads are better paginated or copied to a separate analytics database. It also says OLAP queries can fail if the connection to PlanetScale is interrupted.&lt;/p&gt;

&lt;p&gt;That last point changes how I'd write an export. Suppose a queued job streams a million rows inside one &lt;code&gt;olap()&lt;/code&gt; call and the connection drops at row 900,000. The job retries, and it starts over from row one. For a nightly report nobody is waiting on, that may be fine. For anything with a deadline, I'd rather have a job that remembers where it stopped:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$lastId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Cache&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"export:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:last_id"&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="nc"&gt;Order&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&amp;gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$lastId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;chunkById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$orders&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$orders&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="nc"&gt;Cache&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"export:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:last_id"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$orders&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;last&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;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;addDay&lt;/span&gt;&lt;span class="p"&gt;());&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;That runs entirely in OLTP mode, each chunk is a small query well under every limit, and a retry picks up near where it died. It's more code than &lt;code&gt;DB::olap()&lt;/code&gt;. It's also the version I'd bet on at 2am.&lt;/p&gt;

&lt;p&gt;My rough rule: reach for &lt;code&gt;olap()&lt;/code&gt; when the job is one-off or rare and a restart is cheap. A backfill, an admin report, a data fix. Reach for chunking when the work is recurring, user-facing, or expensive to repeat. And for reporting that keeps growing, stop reading the production database at all and copy the data somewhere built for it, which is exactly what PlanetScale suggests.&lt;/p&gt;

&lt;p&gt;The article frames &lt;code&gt;olap()&lt;/code&gt; around reads, and I'd keep it there. I haven't seen anything that tells me the OLAP workload is a good idea for the update and delete side of that 100,000-row limit, so for bulk writes I'd still chunk and not assume.&lt;/p&gt;

&lt;h2&gt;
  
  
  A guard I'd put around it
&lt;/h2&gt;

&lt;p&gt;Because the method is so easy to call, I'd wrap it once and make the dangerous uses harder. Something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;HeavyRead&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Closure&lt;/span&gt; &lt;span class="nv"&gt;$callback&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;mixed&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nf"&gt;app&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;runningInConsole&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;LogicException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Heavy reads belong in queued jobs or commands.'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="nv"&gt;$start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;microtime&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;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;DB&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;olap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$callback&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nc"&gt;Log&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'heavy read finished'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
                &lt;span class="s1"&gt;'seconds'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;microtime&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;span class="o"&gt;-&lt;/span&gt; &lt;span class="nv"&gt;$start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="p"&gt;]);&lt;/span&gt;
        &lt;span class="p"&gt;}&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;It refuses to run inside a web request, so nobody wires a "download all orders" button straight to an unlimited query. It logs a duration, so you notice when a one-off report quietly becomes a nightly habit. And with the one call site in one file, reviewing for the lazy-collection mistake is a single grep.&lt;/p&gt;

&lt;p&gt;Two smaller details from the article are worth knowing. The same pull request adds &lt;code&gt;isVitess()&lt;/code&gt; to the MySQL connection, which returns true when the server's version string mentions Vitess or PlanetScale. Like &lt;code&gt;isMaria()&lt;/code&gt;, it connects to the database to read that version, so the first call costs a round trip. And if your read connection hasn't connected yet when you call &lt;code&gt;olap()&lt;/code&gt;, the method connects it first. Neither is a problem. Both are the kind of thing I'd want to know before I saw an unexpected connection in a profiler.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would do this week
&lt;/h2&gt;

&lt;p&gt;Search your codebase for exports, reports and backfills that call &lt;code&gt;get()&lt;/code&gt; or &lt;code&gt;all()&lt;/code&gt; on a table that has grown. Those are your candidates, and they're also the jobs most likely to have never been run against production-sized data.&lt;/p&gt;

&lt;p&gt;Then pick one and test it against a PlanetScale development branch with a realistic row count, not a seeded fifty. If it fails on the limit, you have two honest options: wrap it in &lt;code&gt;DB::olap()&lt;/code&gt; with iteration inside the callback, or rewrite it with &lt;code&gt;chunkById()&lt;/code&gt; so a retry resumes. Decide per job using the restart-cost rule above.&lt;/p&gt;

&lt;p&gt;Last, grep for &lt;code&gt;set workload&lt;/code&gt; in your repository. Any raw statement you find is a replica-routing bug or a leaked session waiting to happen, and swapping it for &lt;code&gt;DB::olap()&lt;/code&gt; is a five-minute change.&lt;/p&gt;

&lt;p&gt;If you're running Laravel on PlanetScale and want a second pair of eyes on an export that keeps timing out, that's the kind of backend work I do. You can see more of it on my &lt;a href="https://abrarqasim.com" rel="noopener noreferrer"&gt;portfolio&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://abrarqasim.com/blog/laravel-db-olap-planetscale-100000-row-limit-when-i-still-chunk/" rel="noopener noreferrer"&gt;abrarqasim.com&lt;/a&gt;. I write there about React, PHP, Rust, Go and the AI tooling around them.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>planetscale</category>
      <category>mysql</category>
      <category>vitess</category>
    </item>
    <item>
      <title>Retainer Agreement Template for Web Developers: Clauses That Matter</title>
      <dc:creator>Qasim Parray</dc:creator>
      <pubDate>Thu, 22 Oct 2026 08:38:55 +0000</pubDate>
      <link>https://dev.to/abyzgenic/retainer-agreement-template-for-web-developers-clauses-that-matter-19a4</link>
      <guid>https://dev.to/abyzgenic/retainer-agreement-template-for-web-developers-clauses-that-matter-19a4</guid>
      <description>&lt;p&gt;Picture the lazy version of a retainer. A developer writes it on one line in an email: "Ten hours a month, same rate, invoiced on the first." The client says yes within the hour, and everyone feels very professional for roughly three weeks. Then a request lands on day 29 that is really a small project, the client asks what happens to unused hours, and someone wonders whether "ten hours" includes the time spent reading emails.&lt;/p&gt;

&lt;p&gt;None of that was in the one-liner. That's the job a retainer agreement does, and a one-liner doesn't do it. Below is the template I'd use today for a web developer retainer, the reasoning behind each clause, and the places where I think most templates online quietly fail the developer.&lt;/p&gt;

&lt;p&gt;I'm not a lawyer, and this isn't legal advice. Treat the wording as a starting point and get a local professional to read it before you attach your name to it, especially if your client is in another country.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decide what the client is buying
&lt;/h2&gt;

&lt;p&gt;A retainer can sell three different things, and the agreement should say which one you mean.&lt;/p&gt;

&lt;p&gt;The first is a bank of hours. The client gets a fixed number of hours each month at an agreed rate. This is the easiest to explain and the easiest to argue about, because every hour becomes a unit someone can question.&lt;/p&gt;

&lt;p&gt;The second is availability. The client pays for the right to reach you within a stated response time, whether or not they use it. This is closer to an insurance premium, and it suits maintenance work for a site that mostly runs by itself.&lt;/p&gt;

&lt;p&gt;The third is a defined outcome: for instance, "keep the site patched, backed up and monitored, and fix anything that breaks within one business day". You're selling the result and you absorb the variance in hours.&lt;/p&gt;

&lt;p&gt;I lean toward the third for small sites and the first for active product work. The mistake is mixing them without saying so. If the agreement promises a one-day response time and also caps you at ten hours, you've written a contract that you'll break in the first busy month, and the client has no idea.&lt;/p&gt;

&lt;p&gt;If you haven't already talked through scope with the client, do that before you send anything. My &lt;a href="https://abrarqasim.com/blog/discovery-call-questions-web-client-before-quoting" rel="noopener noreferrer"&gt;list of discovery call questions&lt;/a&gt; is what I'd run through first, because half of these clauses get easier to write once you know what the site actually does.&lt;/p&gt;

&lt;h2&gt;
  
  
  The clauses that matter
&lt;/h2&gt;

&lt;p&gt;Here is the skeleton. Short on purpose.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;RETAINER AGREEMENT

1. Services. The Developer will provide the services listed in Schedule A
   (maintenance, updates, monitoring, and small changes) for the Client's
   website at [domain].

2. Monthly fee. The Client will pay [amount] per month, invoiced on the
   [first] of each month, payable within [14] days.

3. Included time. The fee covers up to [N] hours per month. Time is tracked
   in 15-minute increments and reported monthly.

4. Unused time. Unused hours [expire at month end / roll over for one month].

5. Extra work. Work beyond Schedule A or beyond [N] hours is quoted
   separately and does not start until the Client approves the quote in writing.

6. Response times. The Developer will acknowledge requests within [1]
   business day. Emergency means the site is down or taking payments
   incorrectly; those requests are handled first.

7. Term and ending. This agreement runs month to month. Either party can end
   it with [30] days written notice.

8. Ownership. The Client owns the work product once the month's invoice is paid.
   The Developer keeps rights to general tools and code not specific to the Client.

9. Liability. The Developer's liability is limited to the fees paid in the
   preceding [3] months.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Most of that is boilerplate. A few clauses carry all the weight, and those are the ones I'd spend an evening on.&lt;/p&gt;

&lt;h3&gt;
  
  
  Unused hours
&lt;/h3&gt;

&lt;p&gt;Clause 4 is where the money argument happens. If hours expire, the client feels cheated in quiet months and the developer feels fine. If hours roll over without limit, you can end up owing someone 60 hours at the worst possible moment. I'd pick expiry as the default, and offer a single month of rollover if the client pushes. Whatever you choose, write it down. An unstated rollover rule means the client will assume the generous version.&lt;/p&gt;

&lt;h3&gt;
  
  
  What counts as extra
&lt;/h3&gt;

&lt;p&gt;Clause 5 is the one the day-29 request breaks. Define "small change" with a number, not an adjective. Something like "any single request that I estimate at more than three hours is a project and gets its own quote". Then the client doesn't have to argue with your opinion of what "small" means. They can argue with an estimate, which is a much more productive fight.&lt;/p&gt;

&lt;p&gt;Approval in writing matters because it protects both sides. An email reply is fine. A verbal "yeah go ahead" on a call is how a retainer becomes unpaid project work.&lt;/p&gt;

&lt;h3&gt;
  
  
  Ending it
&lt;/h3&gt;

&lt;p&gt;Month to month with notice is boring and it's the right answer for most small clients. Long fixed terms scare people off, and a client who can't leave tends to become a client who resents you. A 30-day notice period gives you time to hand over credentials and documentation, and it gives them time to find someone else without a gap in coverage.&lt;/p&gt;

&lt;h2&gt;
  
  
  Billing it so it actually gets paid
&lt;/h2&gt;

&lt;p&gt;A retainer that depends on you remembering to send an invoice on the first of the month will fail in the month you're busiest, which is also the month the client most needs the cash flow to be dull and predictable.&lt;/p&gt;

&lt;p&gt;If your clients can pay by card, a recurring subscription takes the chasing out of it. Stripe documents the model in its &lt;a href="https://docs.stripe.com/billing/subscriptions/overview" rel="noopener noreferrer"&gt;subscriptions overview&lt;/a&gt;: you create a price, attach the customer, and the billing happens on a schedule without you. For clients who need a proper invoice with a PO number or bank transfer details, &lt;a href="https://docs.stripe.com/invoicing" rel="noopener noreferrer"&gt;Stripe Invoicing&lt;/a&gt; can generate and send those on a schedule too. Neither is the only option, and I don't have a stake in the choice. The point is to remove yourself from the loop.&lt;/p&gt;

&lt;p&gt;Two things I'd add. First, send the hours report with the invoice, even when the client never asks for it. A short list of what you did builds trust faster than any promise about quality. Second, decide in advance what happens when a payment fails. A sensible default is a reminder at day 7, a pause on non-emergency work at day 14, and a conversation by phone before anything harsher. Write that sequence into clause 2 or a short schedule so it isn't a personal decision at the worst moment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where templates online get it wrong
&lt;/h2&gt;

&lt;p&gt;Free retainer templates tend to share the same weaknesses.&lt;/p&gt;

&lt;p&gt;Many are written for consultants or agencies and talk about "deliverables" and "milestones" in a way that doesn't fit ongoing maintenance. A retainer is a service relationship, not a project with an end date.&lt;/p&gt;

&lt;p&gt;Many bury the response time or skip it. Clients buy retainers because they want to know that someone will pick up the phone. If the agreement doesn't say how fast, the client will fill in "immediately" in their head.&lt;/p&gt;

&lt;p&gt;And some give the developer almost no protection on scope. A line like "ongoing website support" is an invitation. Schedule A should list what is included in plain words, such as plugin and framework updates, uptime monitoring, nightly backups with a monthly restore test, and small content or layout edits. It should also list what is excluded: new features, redesigns, third-party integrations, and anything caused by changes someone else made to the server.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://www.freelancersunion.org/" rel="noopener noreferrer"&gt;Freelancers Union&lt;/a&gt; publishes free resources on contracts and getting paid, and it's worth a read if you're newer to this. If you want my view on the broader contract, I wrote up &lt;a href="https://abrarqasim.com/blog/freelance-contract-template-six-questions-before-i-sign" rel="noopener noreferrer"&gt;six questions I ask before I sign&lt;/a&gt;, and most of them apply here too.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do this week
&lt;/h2&gt;

&lt;p&gt;Open the last retainer proposal you sent, or the email where you described one, and check it against the skeleton above. Find the three clauses it's missing. For most people that's unused hours, the definition of extra work, and the notice period.&lt;/p&gt;

&lt;p&gt;Then write Schedule A as two short lists, included and excluded, and send the updated version to your next client before they ask. If you already have clients on a retainer with no written agreement, send them the new one with a short note and offer to talk it through. Nobody has ever been upset to be asked what happens to the unused hours before the answer matters.&lt;/p&gt;

&lt;p&gt;If you'd like to see how I scope and price this sort of work, my &lt;a href="https://abrarqasim.com" rel="noopener noreferrer"&gt;portfolio&lt;/a&gt; is the place to start.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://abrarqasim.com/blog/retainer-agreement-template-web-developer-clauses-that-matter/" rel="noopener noreferrer"&gt;abrarqasim.com&lt;/a&gt;. I write there about React, PHP, Rust, Go and the AI tooling around them.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>freelance</category>
      <category>contracts</category>
      <category>retainer</category>
      <category>pricing</category>
    </item>
    <item>
      <title>Rust vs C++: Who Checks the Lifetime, and When</title>
      <dc:creator>Qasim Parray</dc:creator>
      <pubDate>Wed, 21 Oct 2026 16:38:53 +0000</pubDate>
      <link>https://dev.to/abyzgenic/rust-vs-c-who-checks-the-lifetime-and-when-5ace</link>
      <guid>https://dev.to/abyzgenic/rust-vs-c-who-checks-the-lifetime-and-when-5ace</guid>
      <description>&lt;p&gt;Short version for the impatient: the real difference between Rust and C++ is not speed. Both compile to native code and both can hit the same hardware limits. The difference is who checks your lifetimes, and when. C++ lets you check them yourself, with discipline and tooling. Rust makes the compiler check them before the program exists.&lt;/p&gt;

&lt;p&gt;I want to show that with a small program rather than a slogan, because I've read a lot of "Rust vs C++" posts that are just two benchmark charts and a fan club. I write mostly PHP, Go and TypeScript for clients, so I'm not a systems programmer, and I won't pretend to have shipped a game engine in either language. What I can do is take a mistake that every C++ beginner makes, run it on a real machine, and compare it to what Rust does with the same idea.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug that compiles
&lt;/h2&gt;

&lt;p&gt;Here is a C++ program that moves a string out of a smart pointer and then reads from the pointer anyway.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="cp"&gt;#include&lt;/span&gt; &lt;span class="cpf"&gt;&amp;lt;iostream&amp;gt;&lt;/span&gt;&lt;span class="cp"&gt;
#include&lt;/span&gt; &lt;span class="cpf"&gt;&amp;lt;memory&amp;gt;&lt;/span&gt;&lt;span class="cp"&gt;
#include&lt;/span&gt; &lt;span class="cpf"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;&lt;span class="cp"&gt;
&lt;/span&gt;
&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;auto&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;make_unique&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"report.pdf"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;auto&lt;/span&gt; &lt;span class="n"&gt;other&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;move&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;cout&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;"&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;I built this with &lt;code&gt;g++ -Wall -Wextra&lt;/code&gt; on a Linux box. No warnings. No errors. It ran and died with a segmentation fault (exit code 139). The move constructor did exactly what it promises: &lt;code&gt;name&lt;/code&gt; is now an empty pointer, and dereferencing an empty pointer is undefined behaviour. The compiler knew that &lt;code&gt;std::move&lt;/code&gt; had just been called on &lt;code&gt;name&lt;/code&gt;. It let me carry on anyway, because the language says a moved-from object is valid but unspecified, and the language leaves it to me to remember.&lt;/p&gt;

&lt;p&gt;That's not a knock on C++. A segfault in a toy program is the good outcome. The bad outcome is the same mistake inside a request handler where the pointer isn't null but points at memory something else now owns. Nothing crashes. Something gets quietly overwritten.&lt;/p&gt;

&lt;p&gt;Now the same idea in Rust:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;String&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"report.pdf"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;other&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nd"&gt;println!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"{name} {other}"&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;This does not build. The compiler stops with error E0382, "borrow of moved value", and points at the line where &lt;code&gt;name&lt;/code&gt; was moved and the line where I used it afterwards. There is no runtime to crash, because there is no executable. The &lt;a href="https://doc.rust-lang.org/book/ch04-01-what-is-ownership.html" rel="noopener noreferrer"&gt;Rust Book chapter on ownership&lt;/a&gt; states the rules in three lines: each value has an owner, there is one owner at a time, and when the owner goes out of scope the value is dropped. The move bug above is simply the second rule being enforced.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ownership is a rule, not a convention
&lt;/h2&gt;

&lt;p&gt;C++ has all the same ideas. Smart pointers, RAII and move semantics are the C++ answer to manual memory management, and modern C++ written with them is far safer than the C-with-classes code that gave the language its reputation. The &lt;a href="https://github.com/isocpp/CppCoreGuidelines/blob/master/CppCoreGuidelines.md" rel="noopener noreferrer"&gt;C++ Core Guidelines&lt;/a&gt; are full of advice in exactly this direction: prefer &lt;code&gt;unique_ptr&lt;/code&gt;, avoid raw owning pointers, don't hold references past the lifetime of the thing they point at.&lt;/p&gt;

&lt;p&gt;So if both languages have the idea, what's the difference? Enforcement. In C++ the guidelines are guidelines. A tool such as clang-tidy or a static analyser can flag many violations, and a code reviewer can catch more, but the language itself accepts the program. In Rust the same rules are part of the type system, and a program that breaks them is not a valid Rust program.&lt;/p&gt;

&lt;p&gt;I find it useful to think of it as the difference between a speed limit sign and a speed bump. Most drivers slow down for both. Only one of them works at 3am when you're tired.&lt;/p&gt;

&lt;p&gt;There's a cost, and I'll get to it. First, the second kind of bug, because it is the one that actually worries me.&lt;/p&gt;

&lt;h2&gt;
  
  
  Dangling references and the lifetime annotation
&lt;/h2&gt;

&lt;p&gt;Returning a reference to something that no longer exists is the classic. In C++ it looks harmless:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;longer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;size&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;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;longer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"a long literal"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"hi"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;cout&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;"&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;The temporary &lt;code&gt;std::string("hi")&lt;/code&gt; is destroyed at the end of the full expression. If it happened to be the longer one, &lt;code&gt;r&lt;/code&gt; would now refer to a dead object. Here the literal wins, so this particular run probably prints correctly, which is the whole problem: it works until the inputs change. I'm describing that behaviour from the language rules, I didn't run this one to see what my compiler does with it.&lt;/p&gt;

&lt;p&gt;Rust forces you to say how long each reference lives:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="n"&gt;longest&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nv"&gt;'a&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nv"&gt;'a&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nv"&gt;'a&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nv"&gt;'a&lt;/span&gt; &lt;span class="nb"&gt;str&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;a&lt;/span&gt;&lt;span class="nf"&gt;.len&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;b&lt;/span&gt;&lt;span class="nf"&gt;.len&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;short&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;String&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"hi"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;longest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"longer text"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;short&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nd"&gt;println!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"{result}"&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;The &lt;code&gt;'a&lt;/code&gt; says that the result lives no longer than the shorter of the two inputs. Because &lt;code&gt;short&lt;/code&gt; is dropped at the closing brace and &lt;code&gt;result&lt;/code&gt; is used after it, the compiler rejects the program with E0597, "&lt;code&gt;short&lt;/code&gt; does not live long enough". The function is correct. The caller is wrong, and Rust tells the caller.&lt;/p&gt;

&lt;p&gt;Those annotations are the most disliked part of the language, and I get why. They look like noise until you've been bitten by the thing they prevent. The &lt;a href="https://doc.rust-lang.org/nomicon/meet-safe-and-unsafe.html" rel="noopener noreferrer"&gt;Rustonomicon&lt;/a&gt; is blunt about where the boundary sits: in safe Rust the compiler guarantees the absence of this class of bug, and in &lt;code&gt;unsafe&lt;/code&gt; blocks the guarantee becomes your job again. That is a much smaller area to audit than "the whole codebase".&lt;/p&gt;

&lt;h2&gt;
  
  
  What Rust costs you
&lt;/h2&gt;

&lt;p&gt;I'd be doing the thing I complained about at the top if I stopped here. Rust costs real time.&lt;/p&gt;

&lt;p&gt;The borrow checker will reject programs that are fine. Graph structures, doubly linked lists and anything with cyclic references fight the ownership model, and the usual escape hatches (&lt;code&gt;Rc&amp;lt;RefCell&amp;lt;T&amp;gt;&amp;gt;&lt;/code&gt;, indices into a &lt;code&gt;Vec&lt;/code&gt;, &lt;code&gt;unsafe&lt;/code&gt;) each give up something. When I read Rust written by people who are new to it, the pattern I see most is &lt;code&gt;.clone()&lt;/code&gt; sprinkled on until the errors go away. That works, and it quietly spends the performance you chose Rust to keep.&lt;/p&gt;

&lt;p&gt;Compile times are slower than I'd like. The ecosystem is younger, so a library you take for granted in C++ for audio, CAD or an old hardware vendor's SDK may not exist, or may be a thin wrapper over the C++ one anyway. And if you have a decade of C++ in production, rewriting it is a business decision, not a technical one. I covered a smaller version of that trade in my post on &lt;a href="https://abrarqasim.com/blog/rust-1-99-vec-into-parts-and-the-box-leak-habit-i-had-to-drop" rel="noopener noreferrer"&gt;Rust 1.99 and the box leak habit I had to drop&lt;/a&gt;, where even a language that polices ownership still lets you leak memory on purpose if you ask.&lt;/p&gt;

&lt;p&gt;C++ has costs too, and they're mostly invisible until they aren't. The language is huge, with decades of overlapping ways to do one thing. Two engineers on the same team can write "modern C++" that looks like two different languages. Undefined behaviour doesn't announce itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I'd choose
&lt;/h2&gt;

&lt;p&gt;I'd pick C++ when the code has to live next to an existing C++ codebase, when a library only exists there, or when the team already has people who know where the bodies are buried. Rewriting a working system to get memory safety is rarely worth it by itself, and incremental approaches that put new code in Rust next to the old code are usually kinder to the schedule.&lt;/p&gt;

&lt;p&gt;I'd pick Rust for new code that handles untrusted input, runs for a long time, or gets maintained by people who weren't in the room when it was written. Parsers, network services, command-line tools and anything that sits between the internet and a database are good fits. The ownership rules double as documentation. When a function takes &lt;code&gt;String&lt;/code&gt; instead of &lt;code&gt;&amp;amp;str&lt;/code&gt;, that signature tells the next developer who is responsible for the memory.&lt;/p&gt;

&lt;p&gt;For the kind of work I do for clients, neither is the default. A Laravel app or a Go service covers most of what people hire me for, and I reach for a systems language only when a hot path justifies it. You can see what that mix looks like on &lt;a href="https://abrarqasim.com" rel="noopener noreferrer"&gt;my portfolio&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it yourself this week
&lt;/h2&gt;

&lt;p&gt;Don't take my word, or any benchmark's word, for any of this. Spend an hour on it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Write the &lt;code&gt;unique_ptr&lt;/code&gt; move bug above in C++ and compile it with &lt;code&gt;-Wall -Wextra&lt;/code&gt;. Then compile the same file with &lt;code&gt;-fsanitize=address,undefined&lt;/code&gt; and run it. See what each tool tells you.&lt;/li&gt;
&lt;li&gt;Install Rust with rustup and write the &lt;code&gt;longest&lt;/code&gt; function with the nested block. Read the full E0597 message, not just the first line. Then delete the &lt;code&gt;'a&lt;/code&gt; annotations and read the different error you get.&lt;/li&gt;
&lt;li&gt;Pick one small function from your own C++ code that returns a reference or a pointer. Write down, in a comment, who owns the thing it points at. If you can't answer in one sentence, you've found the kind of bug Rust is built to catch.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If step three turns up a real question, that is a better reason to learn Rust than anything I've written here.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://abrarqasim.com/blog/rust-vs-cpp-who-checks-the-lifetime-moved-pointer-segfault/" rel="noopener noreferrer"&gt;abrarqasim.com&lt;/a&gt;. I write there about React, PHP, Rust, Go and the AI tooling around them.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>rust</category>
      <category>cpp</category>
      <category>memorysafety</category>
      <category>ownership</category>
    </item>
    <item>
      <title>Discovery Call Questions I Ask Every Web Client Before Quoting</title>
      <dc:creator>Qasim Parray</dc:creator>
      <pubDate>Sun, 18 Oct 2026 08:38:46 +0000</pubDate>
      <link>https://dev.to/abyzgenic/discovery-call-questions-i-ask-every-web-client-before-quoting-3kdi</link>
      <guid>https://dev.to/abyzgenic/discovery-call-questions-i-ask-every-web-client-before-quoting-3kdi</guid>
      <description>&lt;p&gt;My worst quote ever was for a "simple brochure site." I priced it in ten minutes after a friendly twenty-minute call, and the client was lovely. Three weeks in, I learned that "brochure" included a members area, an Arabic and English version, and an import of 900 products from a spreadsheet nobody had opened since 2019. I did the work. I also earned roughly the hourly rate of a part-time barista.&lt;/p&gt;

&lt;p&gt;Nothing was wrong with the client. The call was wrong. I asked what they wanted and wrote down the answer, which is a different thing from finding out what they needed. Since then I've kept a short list of discovery call questions, and I skip none of them, even when a lead is charming and in a hurry. Especially then.&lt;/p&gt;

&lt;p&gt;This post is that list, with the reason behind each question and the answer that makes me slow down. It's built for freelance web projects, but most of it works for any small agency job.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ask about the problem before the website
&lt;/h2&gt;

&lt;p&gt;"What do you want built?" gets you a feature list. The feature list is usually a guess at a solution to a problem you haven't heard yet. So I open with a different question: what happened that made you decide to do this now?&lt;/p&gt;

&lt;p&gt;Something always happened. A competitor launched a new site, a developer left, the old site got hacked, a campaign needs a landing page by March. The trigger tells you the real deadline and the real stakes, and it tells you who is nervous. A site rebuild triggered by "our agent quit and the site is down" is a very different job from one triggered by "we feel dated."&lt;/p&gt;

&lt;p&gt;Rob Fitzpatrick's &lt;a href="https://www.momtestbook.com/" rel="noopener noreferrer"&gt;The Mom Test&lt;/a&gt; is a book about customer interviews for startups, but its core idea transfers cleanly: ask about specific past events instead of hypothetical futures. Nobody can reliably tell you whether they'd use a feature. They can tell you what they did last Tuesday when the old system failed. I now ask "walk me through the last time a customer tried to book something on your current site" and I take notes like a court reporter.&lt;/p&gt;

&lt;p&gt;Two follow-ups I always add. What does success look like in six months, in numbers if possible? And what would make you call this project a failure? The second one is the better question. People answer it fast, and the answers are specific.&lt;/p&gt;

&lt;h2&gt;
  
  
  Find out who decides and who pays
&lt;/h2&gt;

&lt;p&gt;I've been burned by the sponsor who loves my proposal and then needs "sign-off from the partners." So early in the call I ask who else will have an opinion about this project, and who signs the invoice.&lt;/p&gt;

&lt;p&gt;If the answer is a committee, I ask to meet the loudest member, or at least to get their written requirements before I quote. A decision-maker I never meet is a stakeholder who will appear at the end with a list of changes. That connects directly to scope, which I covered in &lt;a href="https://abrarqasim.com/blog/how-to-handle-scope-creep-the-quick-one-that-cost-me-a-week" rel="noopener noreferrer"&gt;how I handle scope creep&lt;/a&gt;. Most scope creep I've dealt with was an undiscovered stakeholder in disguise.&lt;/p&gt;

&lt;p&gt;The budget question makes freelancers squirm, so here's the phrasing I use: "Projects like this usually land somewhere between X and Y depending on the answers to what we're discussing. Does that range sit in the same world as what you had in mind?" It's not a trick. It saves us both a week of proposal writing if the number is a different order of magnitude from their expectation. The answer I dislike most is "we don't have a budget in mind." That usually means they've never bought this before, and I need to spend more of the call explaining what good work costs. I wrote about how I arrive at those ranges in &lt;a href="https://abrarqasim.com/blog/how-i-actually-price-freelance-web-development-work" rel="noopener noreferrer"&gt;how I actually price freelance web development work&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ask what already exists
&lt;/h2&gt;

&lt;p&gt;This is the section where my brochure-site quote fell apart. Before any quoting, I want an inventory of what is already out there, and I ask it as plainly as that.&lt;/p&gt;

&lt;p&gt;What is the current site built on? Who has the hosting login and the domain registrar login? Is there content to migrate, and how much of it is real versus abandoned? Are there integrations: payments, a CRM, a booking tool, a newsletter list? Is there analytics, and does anyone read it?&lt;/p&gt;

&lt;p&gt;Migration deserves its own paragraph because it is where hours vanish. If the URLs are changing, someone has to map old addresses to new ones and set up redirects, or the client's search traffic quietly dies. Google's &lt;a href="https://developers.google.com/search/docs/crawling-indexing/site-move-with-url-changes" rel="noopener noreferrer"&gt;guide to site moves with URL changes&lt;/a&gt; is the document I send people who think a redesign is only a visual job. I ask for a crawl or a sitemap export on the call, and I get a rough page count before I say a price out loud. "About 40 pages" and "about 900 URLs" are not the same project, and clients are often honestly unaware of which one they have.&lt;/p&gt;

&lt;p&gt;Domain access is the unglamorous question that has stalled more launches than any technical problem. If the registrar account belongs to a former employee's personal email, I want to know in week one, not launch day.&lt;/p&gt;

&lt;h2&gt;
  
  
  Constraints nobody mentions unless you ask
&lt;/h2&gt;

&lt;p&gt;Some requirements are invisible until they're violated. I ask about them directly, because the client assumes they're obvious.&lt;/p&gt;

&lt;p&gt;Languages and right-to-left layouts, for one. Accessibility, for another: if the client is a government body, a school or a larger company, there may be a legal or contractual standard, and the &lt;a href="https://www.w3.org/WAI/planning-and-managing/" rel="noopener noreferrer"&gt;W3C Web Accessibility Initiative's planning guidance&lt;/a&gt; is a good reference for the conversation. Retrofitting accessibility at the end costs far more than designing for it from the start, so I'd rather know now.&lt;/p&gt;

&lt;p&gt;Then there's who maintains the site after launch. If the answer is "the marketing intern, in WordPress," that shapes my technology choices more than anything else on this list. I once proposed a lovely headless setup to a client whose only technical person was an office manager. It was the wrong tool for them and I knew it halfway through the call, but I had already started getting excited. Now I ask the maintenance question early enough that it can veto my enthusiasm.&lt;/p&gt;

&lt;p&gt;Other quick ones: any hard launch date and what it's tied to, any brand guidelines or a designer already involved, any legal copy (privacy policy, cookie consent) they need, and whether they have photos or I should budget for stock. Each takes thirty seconds to ask and has cost me a day when I didn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Close the call with a promise you can keep
&lt;/h2&gt;

&lt;p&gt;At the end I repeat back what I heard in about five sentences, and I say when they'll get a written proposal. Then I do exactly that. The recap catches misunderstandings while they're cheap to fix, and clients often correct one detail in a way that changes the quote.&lt;/p&gt;

&lt;p&gt;I also tell them what I will need from them: a named decision-maker, access to current accounts, and any existing content. Setting that expectation before the contract keeps "waiting on the client" from becoming my fault in a timeline conversation later. When the call went well, the proposal nearly writes itself, and I've shared the structure I use in &lt;a href="https://abrarqasim.com/blog/web-development-proposal-template-the-section-that-wins-the-job" rel="noopener noreferrer"&gt;my web development proposal template&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Not every lead should get a proposal, and discovery is also where I find that out. A client who refuses to say who decides, won't discuss budget and wants a fixed price by Friday is giving me information. Sometimes the best output of a discovery call is a polite no.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it on your next call
&lt;/h2&gt;

&lt;p&gt;You don't need the whole list. Pick the three questions you've been skipping because they feel awkward, probably the budget one and the "who else decides" one, and write them at the top of your call notes before your next lead. Ask them in the first fifteen minutes, when it feels slightly rude, instead of the last five, when it feels like an interrogation.&lt;/p&gt;

&lt;p&gt;After the call, compare what you learned with what you would have quoted from a form submission. If the gap is large, you've found your own version of my brochure site. This is the filter I use for the projects that come through &lt;a href="https://abrarqasim.com" rel="noopener noreferrer"&gt;my freelance practice&lt;/a&gt;, and it has saved me from more bad fits than any contract clause.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://abrarqasim.com/blog/discovery-call-questions-web-client-before-quoting/" rel="noopener noreferrer"&gt;abrarqasim.com&lt;/a&gt;. I write there about React, PHP, Rust, Go and the AI tooling around them.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>freelance</category>
      <category>clients</category>
      <category>discoverycall</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Linux Server Hardening on a Small VPS: The Baseline I Run First</title>
      <dc:creator>Qasim Parray</dc:creator>
      <pubDate>Sat, 17 Oct 2026 16:38:44 +0000</pubDate>
      <link>https://dev.to/abyzgenic/linux-server-hardening-on-a-small-vps-the-baseline-i-run-first-58np</link>
      <guid>https://dev.to/abyzgenic/linux-server-hardening-on-a-small-vps-the-baseline-i-run-first-58np</guid>
      <description>&lt;p&gt;I once locked myself out of a client's server at 11pm because I reloaded sshd with a typo in the config and closed my only terminal. The fix involved a rescue console, a cold coffee and a long apology. That night taught me more about linux server hardening than any checklist had, and it is why the first rule in this post is about what to do before you change anything.&lt;/p&gt;

&lt;p&gt;Short version for the impatient: on a fresh small VPS I do five things. Key-only SSH, no root login, a deny-by-default firewall, fail2ban for the noise, and automatic security updates. That takes about ten minutes, and it removes nearly everything that actually gets small servers owned. If you want the reasoning and the config, keep reading.&lt;/p&gt;

&lt;p&gt;I run client apps on cheap VPS boxes, mostly Ubuntu 24.04, and I'm not a security specialist. This is a baseline for a server that hosts a web app, a database and a few containers. It is not a compliance program. If you need CIS-level hardening, the &lt;a href="https://www.cisecurity.org/benchmark/ubuntu_linux" rel="noopener noreferrer"&gt;CIS Ubuntu benchmark&lt;/a&gt; is the document to start with, and it is much longer than what follows.&lt;/p&gt;

&lt;h2&gt;
  
  
  Open a second terminal before you touch sshd
&lt;/h2&gt;

&lt;p&gt;Here is the habit that would have saved my night. Log in twice. Keep the second session idle and never close it until you have proven that a brand new connection works. A running SSH session survives an sshd reload, so the idle one is your way back in.&lt;/p&gt;

&lt;p&gt;Then validate before you reload. &lt;code&gt;sshd -t&lt;/code&gt; parses the config and prints errors without touching the running daemon. It costs one second. I skipped it exactly once.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;sshd &lt;span class="nt"&gt;-t&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl reload ssh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that command prints nothing and returns, the config is syntactically fine. It does not tell you the config is what you meant, which is why the second terminal stays open and you test a fresh login from a third window.&lt;/p&gt;

&lt;h2&gt;
  
  
  SSH: keys only, no root, short leash
&lt;/h2&gt;

&lt;p&gt;Most of the hardening value lives here, because SSH is the one door every VPS has open to the whole internet from minute one. Watch &lt;code&gt;/var/log/auth.log&lt;/code&gt; on a new box for ten minutes and you will see strangers trying &lt;code&gt;admin&lt;/code&gt;, &lt;code&gt;ubuntu&lt;/code&gt; and &lt;code&gt;root&lt;/code&gt; with passwords from a list.&lt;/p&gt;

&lt;p&gt;I put my changes in a drop-in file instead of editing the main config. On Ubuntu, &lt;code&gt;/etc/ssh/sshd_config&lt;/code&gt; includes everything in &lt;code&gt;/etc/ssh/sshd_config.d/*.conf&lt;/code&gt;, and sshd uses the first value it finds for each setting. So the filename matters. Cloud images often ship a file like &lt;code&gt;50-cloud-init.conf&lt;/code&gt; that sets &lt;code&gt;PasswordAuthentication yes&lt;/code&gt;, and because the first value wins, a file that sorts after it loses. I name mine &lt;code&gt;01-hardening.conf&lt;/code&gt; so it is read first, and then I check the effective result instead of trusting my eyes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# /etc/ssh/sshd_config.d/01-hardening.conf&lt;/span&gt;
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitRootLogin no
MaxAuthTries 3
LoginGraceTime 20
AllowUsers deploy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My first attempt used a 99 prefix and silently lost to the cloud-init file. So after every change I run &lt;code&gt;sudo sshd -T | grep -Ei 'passwordauth|permitroot|maxauth'&lt;/code&gt;, which prints the settings sshd will actually use. I got this wrong for a while by reading the files instead of asking the daemon, and the daemon disagreed with me twice.&lt;/p&gt;

&lt;p&gt;A few notes on those lines, all checked against the &lt;a href="https://man.openbsd.org/sshd_config" rel="noopener noreferrer"&gt;sshd_config man page&lt;/a&gt;. &lt;code&gt;PermitRootLogin&lt;/code&gt; already defaults to &lt;code&gt;prohibit-password&lt;/code&gt;, so root can't log in with a password out of the box, but setting it to &lt;code&gt;no&lt;/code&gt; removes the question entirely. &lt;code&gt;MaxAuthTries&lt;/code&gt; defaults to 6, and I drop it to 3 because I authenticate with a key and never need six attempts. &lt;code&gt;KbdInteractiveAuthentication no&lt;/code&gt; matters because with PAM enabled, keyboard-interactive can sneak password logins back in even when &lt;code&gt;PasswordAuthentication&lt;/code&gt; is off.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;AllowUsers deploy&lt;/code&gt; is the line people skip and I think it deserves more credit. Every account that isn't named there is refused no matter what else is true. Add a new service account later and forget about it, and it still can't log in over SSH.&lt;/p&gt;

&lt;p&gt;One caveat on moving SSH to another port. I used to do it. It cuts log noise a lot and stops nothing, since a scanner finds port 2222 in seconds. If your logs are loud, fail2ban handles that better and doesn't break every tool that assumes port 22.&lt;/p&gt;

&lt;h2&gt;
  
  
  A firewall that denies by default
&lt;/h2&gt;

&lt;p&gt;SSH is not the only thing listening. A fresh image may have a database, a forgotten Redis, a metrics agent or a dev server bound to all interfaces, and you may not remember starting it. A host firewall makes the answer to "what is exposed" a short list you wrote yourself.&lt;/p&gt;

&lt;p&gt;On Ubuntu that's ufw, which ships with the system. The order of commands matters, because enabling a deny-by-default firewall before allowing SSH is another way to lock yourself out.&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;sudo &lt;/span&gt;ufw default deny incoming
&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw default allow outgoing
&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw limit OpenSSH
&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw allow 80,443/tcp
&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw &lt;span class="nb"&gt;enable
sudo &lt;/span&gt;ufw status verbose
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ufw limit&lt;/code&gt; is &lt;code&gt;allow&lt;/code&gt; plus a rate limit: it throttles an address that opens too many connections in a short window. It's not a replacement for fail2ban, but it's free and one word.&lt;/p&gt;

&lt;p&gt;Now the part that bit me. If you run Docker, ufw does not protect your published container ports the way you would assume. Docker writes its own rules into the firewall, and traffic to a published port is diverted before it ever reaches the rules ufw manages. So &lt;code&gt;-p 5432:5432&lt;/code&gt; on a database container can be open to the internet while &lt;code&gt;ufw status&lt;/code&gt; swears everything is closed. The &lt;a href="https://docs.docker.com/engine/network/packet-filtering-firewalls/" rel="noopener noreferrer"&gt;Docker documentation on ufw&lt;/a&gt; says the two are incompatible in this way and describes the options.&lt;/p&gt;

&lt;p&gt;My rule is dull and effective. Publish ports on loopback only (&lt;code&gt;-p 127.0.0.1:5432:5432&lt;/code&gt;) unless something truly needs the public interface, and put a reverse proxy in front for the rest. For client sites I went one step further and stopped opening inbound ports at all, which I wrote up in &lt;a href="https://abrarqasim.com/blog/cloudflare-tunnel-how-i-stopped-opening-ports-for-clients" rel="noopener noreferrer"&gt;my Cloudflare Tunnel post&lt;/a&gt;. If a port isn't open, there's nothing to firewall.&lt;/p&gt;

&lt;p&gt;After enabling, verify from outside. Run &lt;code&gt;nmap&lt;/code&gt; against your own IP from another machine, or use an online port scanner. Looking at the firewall from the inside only tells you what you configured.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fail2ban for the noise, not for safety
&lt;/h2&gt;

&lt;p&gt;Fail2ban reads logs, spots repeated failed logins and bans the source address in the firewall for a while. I install it on every box, and I try to be honest about what it does. The project's own &lt;a href="https://github.com/fail2ban/fail2ban" rel="noopener noreferrer"&gt;README&lt;/a&gt; says it reduces the rate of incorrect authentication attempts but cannot eliminate the risk of weak authentication. With key-only SSH, brute force can't succeed anyway. What fail2ban buys me is quieter logs and less wasted CPU, which also makes real anomalies easier to spot.&lt;/p&gt;

&lt;p&gt;The defaults in the upstream &lt;code&gt;jail.conf&lt;/code&gt; are a ban time of 10 minutes, a find window of 10 minutes and 5 retries. The sshd jail on Ubuntu is enabled by the packaged defaults, but I still write a small local override instead of editing &lt;code&gt;jail.conf&lt;/code&gt;, because package upgrades can overwrite the latter.&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="c"&gt;# /etc/fail2ban/jail.d/local.conf
&lt;/span&gt;&lt;span class="nn"&gt;[DEFAULT]&lt;/span&gt;
&lt;span class="py"&gt;bantime&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;1h&lt;/span&gt;
&lt;span class="py"&gt;findtime&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;10m&lt;/span&gt;
&lt;span class="py"&gt;maxretry&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;4&lt;/span&gt;

&lt;span class="nn"&gt;[sshd]&lt;/span&gt;
&lt;span class="py"&gt;enabled&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;Then &lt;code&gt;sudo systemctl restart fail2ban&lt;/code&gt; and &lt;code&gt;sudo fail2ban-client status sshd&lt;/code&gt; to see current bans. If you have a fixed IP, put it in the &lt;code&gt;ignoreip&lt;/code&gt; setting so a few fat-fingered logins from your own desk can't ban you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Automatic security updates and the leftovers
&lt;/h2&gt;

&lt;p&gt;Unpatched software is the dull way servers get breached, far more often than anything exotic. Ubuntu's &lt;code&gt;unattended-upgrades&lt;/code&gt; installs security updates on its own, and the &lt;a href="https://documentation.ubuntu.com/server/how-to/software/automatic-updates/" rel="noopener noreferrer"&gt;Ubuntu Server documentation on automatic updates&lt;/a&gt; covers the setup. On most images it's already installed. Check with &lt;code&gt;systemctl status unattended-upgrades&lt;/code&gt; and look at &lt;code&gt;/var/log/unattended-upgrades/&lt;/code&gt; to confirm it actually ran.&lt;/p&gt;

&lt;p&gt;Two things I decide deliberately. First, automatic reboots: the kernel updates need a reboot to take effect, and I enable &lt;code&gt;Unattended-Upgrade::Automatic-Reboot&lt;/code&gt; with a 04:00 reboot time only on boxes where a minute of downtime doesn't matter. On client production servers I leave reboots manual and put it on a monthly calendar reminder. Second, the update scope. The default is security updates only, and I keep it that way. A surprise major-version bump of Postgres at 3am is not a security fix.&lt;/p&gt;

&lt;p&gt;Smaller leftovers that take a minute each:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Remove packages and services you don't use. &lt;code&gt;ss -tlnp&lt;/code&gt; shows what is listening and which process owns it.&lt;/li&gt;
&lt;li&gt;Create a non-root user with sudo, and give it a key before you disable anything.&lt;/li&gt;
&lt;li&gt;Keep &lt;code&gt;/etc/ssh/sshd_config.d/01-hardening.conf&lt;/code&gt; and the fail2ban override in version control so the next server is a copy and paste.&lt;/li&gt;
&lt;li&gt;Take a backup before you start. I use restic, and my &lt;a href="https://abrarqasim.com/blog/restic-backup-small-vps-postgres-dump-retention-restore-test" rel="noopener noreferrer"&gt;restic backup setup for a small VPS&lt;/a&gt; covers the retention and the restore test I keep forgetting to do.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Your next ten minutes
&lt;/h2&gt;

&lt;p&gt;Pick one server you run today, probably the one you set up fastest and have looked at least since. Open a second SSH session first. Then run &lt;code&gt;sudo sshd -T | grep -Ei 'passwordauth|permitroot|maxauth|allowusers'&lt;/code&gt; and read what the daemon says, not what you remember. If password authentication is still on, fix that before anything else in this post.&lt;/p&gt;

&lt;p&gt;After that, run &lt;code&gt;sudo ufw status verbose&lt;/code&gt; and &lt;code&gt;ss -tlnp&lt;/code&gt; side by side. Every listening port with no matching reason to be public is your homework for this week. I do this kind of review on client infrastructure as part of &lt;a href="https://abrarqasim.com" rel="noopener noreferrer"&gt;my freelance work&lt;/a&gt;, and the list it produces is almost always shorter than people fear and more embarrassing than they expect.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://abrarqasim.com/blog/linux-server-hardening-small-vps-baseline-i-run-first/" rel="noopener noreferrer"&gt;abrarqasim.com&lt;/a&gt;. I write there about React, PHP, Rust, Go and the AI tooling around them.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>linux</category>
      <category>security</category>
      <category>vps</category>
      <category>ssh</category>
    </item>
    <item>
      <title>Postgres Materialized View Auto Refresh with pg_ivm 1.16</title>
      <dc:creator>Qasim Parray</dc:creator>
      <pubDate>Sat, 17 Oct 2026 08:38:44 +0000</pubDate>
      <link>https://dev.to/abyzgenic/postgres-materialized-view-auto-refresh-with-pgivm-116-35i3</link>
      <guid>https://dev.to/abyzgenic/postgres-materialized-view-auto-refresh-with-pgivm-116-35i3</guid>
      <description>&lt;p&gt;Okay, confession: for a long time my answer to "the dashboard is stale" was a cron job that ran &lt;code&gt;REFRESH MATERIALIZED VIEW&lt;/code&gt; every five minutes and a shrug. It works. It also rebuilds the entire view every time, even when one row changed since the last run. If you've ever watched that refresh crawl on a big table and wondered why Postgres can't just apply the one change, this post is for you.&lt;/p&gt;

&lt;p&gt;Short version: it can, with an extension called pg_ivm. It keeps a materialized view current inside the same transaction that changes the base table, using triggers instead of a scheduled rebuild. The project shipped &lt;a href="https://www.postgresql.org/about/news/pg_ivm-116-released-3391/" rel="noopener noreferrer"&gt;version 1.16&lt;/a&gt; this month with PostgreSQL 19 support. I read the whole &lt;a href="https://github.com/sraoss/pg_ivm" rel="noopener noreferrer"&gt;README&lt;/a&gt; rather than the release note, because the restrictions section is where this extension is won or lost. I haven't put it in front of a client database yet, so everything below is what the docs promise and what I'd test.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem with REFRESH MATERIALIZED VIEW
&lt;/h2&gt;

&lt;p&gt;A normal materialized view is a snapshot. You create it from a query, Postgres stores the result as a table, and it stays frozen until you run &lt;a href="https://www.postgresql.org/docs/current/sql-refreshmaterializedview.html" rel="noopener noreferrer"&gt;&lt;code&gt;REFRESH MATERIALIZED VIEW&lt;/code&gt;&lt;/a&gt;. That command recomputes the full query and swaps in the result. With the &lt;code&gt;CONCURRENTLY&lt;/code&gt; option readers aren't blocked, but the work is still a full recompute.&lt;/p&gt;

&lt;p&gt;The pg_ivm README has a timing example that shows the gap well. These are the project's numbers on a pgbench dataset with 10 million account rows, not mine, so treat them as the shape of the thing rather than a promise for your hardware. Updating one account row took about 9 ms. Refreshing the plain materialized view afterwards took 20.5 seconds. That is the whole reason the polling approach feels wrong: one row changed, and you paid for ten million.&lt;/p&gt;

&lt;p&gt;The usual workaround is what I did. Refresh on a timer, accept stale reads, and pick the timer by arguing with whoever owns the dashboard. Postgres has no built-in alternative, because incremental maintenance of arbitrary queries is a hard problem. That's the gap pg_ivm fills.&lt;/p&gt;

&lt;h2&gt;
  
  
  What pg_ivm does differently
&lt;/h2&gt;

&lt;p&gt;The README calls the result an Incrementally Maintainable Materialized View, or IMMV. You create one with a function instead of &lt;code&gt;CREATE MATERIALIZED VIEW&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- before: a plain materialized view, refreshed by cron&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;MATERIALIZED&lt;/span&gt; &lt;span class="k"&gt;VIEW&lt;/span&gt; &lt;span class="n"&gt;mv_normal&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;aid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;abalance&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bbalance&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pgbench_accounts&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;pgbench_branches&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bid&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- after: an incrementally maintained one&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;pgivm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;create_immv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'immv'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="s1"&gt;'SELECT a.aid, b.bid, a.abalance, b.bbalance
   FROM pgbench_accounts a JOIN pgbench_branches b USING (bid)'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Behind that function call the extension creates triggers on the base tables. When you &lt;code&gt;INSERT&lt;/code&gt;, &lt;code&gt;UPDATE&lt;/code&gt; or &lt;code&gt;DELETE&lt;/code&gt; a row, an &lt;code&gt;AFTER&lt;/code&gt; trigger works out which rows in the view are affected and patches only those. The docs call this immediate maintenance, because the view is updated in the same transaction as the change. There is no window where a reader sees the base table and the view disagree.&lt;/p&gt;

&lt;p&gt;In the README's example, the single-row update on the base table took about 15 ms with the IMMV in place, up from 9 ms without it. So you pay roughly six extra milliseconds on every write to get a view that is always current and never needs a 20-second refresh. Whether that trade is good depends entirely on how often the base table changes, which I'll get to.&lt;/p&gt;

&lt;p&gt;The extension supports PostgreSQL 13 through 19. Installation is &lt;code&gt;make install&lt;/code&gt; plus &lt;code&gt;CREATE EXTENSION pg_ivm;&lt;/code&gt;, and the docs recommend adding it to &lt;code&gt;shared_preload_libraries&lt;/code&gt; or &lt;code&gt;session_preload_libraries&lt;/code&gt; so maintenance works correctly. That needs a restart, so plan it for a maintenance window.&lt;/p&gt;

&lt;h2&gt;
  
  
  The index you must not forget
&lt;/h2&gt;

&lt;p&gt;This is the part of the README I'd highlight with a marker. Incremental updates need to find the right row in the view quickly, and without an index on the view that lookup is a sequential scan. The README shows it directly. With the automatically created index in place, a single-row update took 15 ms. After dropping that index, the same update took about 3.2 seconds.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;create_immv&lt;/code&gt; tries to build a unique index for you when it can derive a key from the query. A &lt;code&gt;GROUP BY&lt;/code&gt; gives it the grouped columns, a &lt;code&gt;DISTINCT&lt;/code&gt; gives it all the columns, and otherwise it looks for all the primary key columns of the base tables in the select list. When it can't find one, it prints a notice telling you to create an index yourself. Please read that notice. If you ignore it, every write to a base table will quietly get slower, and you'll blame the wrong thing.&lt;/p&gt;

&lt;p&gt;The practical rule I'd follow: include the primary keys of your base tables in the select list, check &lt;code&gt;\d&lt;/code&gt; on the new view straight after creating it, and confirm an index exists before you call it done.&lt;/p&gt;

&lt;h2&gt;
  
  
  Aggregates work, with one slow case
&lt;/h2&gt;

&lt;p&gt;Dashboards are mostly aggregates, so this matters. The README shows a &lt;code&gt;GROUP BY&lt;/code&gt; view with &lt;code&gt;count&lt;/code&gt;, &lt;code&gt;sum&lt;/code&gt; and &lt;code&gt;avg&lt;/code&gt;, where updating one account row took about 17 ms and the aggregated numbers for that branch were correct immediately afterwards. The supported aggregates are &lt;code&gt;count&lt;/code&gt;, &lt;code&gt;sum&lt;/code&gt;, &lt;code&gt;avg&lt;/code&gt;, &lt;code&gt;min&lt;/code&gt; and &lt;code&gt;max&lt;/code&gt;, and only built-in ones.&lt;/p&gt;

&lt;p&gt;Two details deserve attention. First, &lt;code&gt;min&lt;/code&gt; and &lt;code&gt;max&lt;/code&gt;. If you delete the row that currently holds the minimum or maximum, Postgres may have to recompute that group from the base tables, and the docs warn that this can take a long time. So a view full of &lt;code&gt;max()&lt;/code&gt; columns on a table where rows get deleted is the wrong candidate.&lt;/p&gt;

&lt;p&gt;Second, floating point. The docs say using &lt;code&gt;sum&lt;/code&gt; or &lt;code&gt;avg&lt;/code&gt; on &lt;code&gt;real&lt;/code&gt; or &lt;code&gt;double precision&lt;/code&gt; columns is unsafe, because the incrementally maintained value can drift from what a fresh calculation would give. Use &lt;code&gt;numeric&lt;/code&gt; instead. Money columns should be &lt;code&gt;numeric&lt;/code&gt; already, but I'd audit anything else that feeds an IMMV.&lt;/p&gt;

&lt;p&gt;The extension also adds hidden columns named like &lt;code&gt;__ivm_count__&lt;/code&gt; to aggregate views, which it uses to maintain the numbers. Don't select them in your own queries, and don't be surprised when you see them in &lt;code&gt;\d&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Restrictions that will bite you
&lt;/h2&gt;

&lt;p&gt;The supported SQL is a subset. Inner and outer joins, &lt;code&gt;DISTINCT&lt;/code&gt;, simple subqueries in &lt;code&gt;FROM&lt;/code&gt;, &lt;code&gt;EXISTS&lt;/code&gt; and simple CTEs are fine. Window functions, &lt;code&gt;HAVING&lt;/code&gt;, &lt;code&gt;ORDER BY&lt;/code&gt;, &lt;code&gt;LIMIT&lt;/code&gt;, &lt;code&gt;UNION&lt;/code&gt; and friends are not allowed. The base tables must be plain tables, which rules out views, partitioned tables and foreign tables. If your biggest table is partitioned, that alone may end the experiment, and I'd rather find that out on day one.&lt;/p&gt;

&lt;p&gt;Outer joins have extra rules: only a simple equijoin, and no aggregates in the same view. Logical replication isn't supported either, so an IMMV on a subscriber won't update when the publisher's base table changes.&lt;/p&gt;

&lt;p&gt;Concurrency is the one I'd read twice. Under &lt;code&gt;READ COMMITTED&lt;/code&gt;, the extension takes an &lt;code&gt;ExclusiveLock&lt;/code&gt; on the view right after a base table changes, so two transactions touching the same IMMV are serialised. Under &lt;code&gt;REPEATABLE READ&lt;/code&gt; or &lt;code&gt;SERIALIZABLE&lt;/code&gt;, it raises an error instead of waiting if it can't get the lock. That's a correctness choice and a sensible one, but it means a hot table feeding an IMMV can become a queue. The README also says plainly that this is a poor fit when the base table is written to frequently or in large batches.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bulk loads, dumps and upgrades
&lt;/h2&gt;

&lt;p&gt;The README gives a clean escape hatch for big writes. Call &lt;code&gt;pgivm.refresh_immv('name', false)&lt;/code&gt; to drop the triggers, load your data, then call it again with &lt;code&gt;true&lt;/code&gt; to rebuild and re-enable maintenance. For a nightly import that's a normal pattern, and I'd script it.&lt;/p&gt;

&lt;p&gt;One more operational detail: the extension's own metadata isn't included in &lt;code&gt;pg_dump&lt;/code&gt; output. Before a dump or &lt;code&gt;pg_upgrade&lt;/code&gt; you run the bundled &lt;code&gt;pg_ivm_dump_metadata&lt;/code&gt; script, and after the restore you run the SQL it produced. If you skip that step, the view's data comes back as an ordinary table with no triggers, and it quietly stops updating. I'd add that to the backup runbook before the first IMMV goes live. If you are still on the basics, my &lt;a href="https://abrarqasim.com/blog/postgres-performance-tuning-parameters-i-actually-touch" rel="noopener noreferrer"&gt;Postgres tuning notes&lt;/a&gt; cover the parameters I actually touch, and this extension is another reason to do a real restore drill.&lt;/p&gt;

&lt;h2&gt;
  
  
  When I'd use it and when I wouldn't
&lt;/h2&gt;

&lt;p&gt;I'd reach for pg_ivm when a view joins a few tables, the base tables change at a modest rate, readers need fresh numbers, and a 20-second rebuild is not acceptable. A customer-facing totals page is the textbook case. I'd keep a cron-refreshed materialized view when the data is allowed to be a few minutes old, the base tables take heavy writes, or the query uses features the extension doesn't support. Plain old stale-but-cheap is a perfectly good answer for a lot of internal reporting.&lt;/p&gt;

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

&lt;p&gt;Pick one materialized view you currently refresh on a timer and check it against the restriction list above: no window functions, plain base tables, only the five aggregates. If it passes, restore a copy of the database onto a scratch server, install the extension, create the IMMV with a primary key in the select list, and time your real write workload against it. Compare the extra write latency with what your refresh job costs today. If you'd like to see the kind of client database work this sits inside, my &lt;a href="https://abrarqasim.com" rel="noopener noreferrer"&gt;portfolio&lt;/a&gt; is the place to start.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://abrarqasim.com/blog/postgres-materialized-view-auto-refresh-pg-ivm-1-16-the-index-i-would-check-first/" rel="noopener noreferrer"&gt;abrarqasim.com&lt;/a&gt;. I write there about React, PHP, Rust, Go and the AI tooling around them.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>materializedviews</category>
      <category>pgivm</category>
      <category>database</category>
    </item>
    <item>
      <title>Restic Backup for a Small VPS: Script, Retention and Restore Test</title>
      <dc:creator>Qasim Parray</dc:creator>
      <pubDate>Thu, 15 Oct 2026 08:38:40 +0000</pubDate>
      <link>https://dev.to/abyzgenic/restic-backup-for-a-small-vps-script-retention-and-restore-test-5ho2</link>
      <guid>https://dev.to/abyzgenic/restic-backup-for-a-small-vps-script-retention-and-restore-test-5ho2</guid>
      <description>&lt;p&gt;A backup you have never restored is a hope, not a backup. I have said that sentence to clients so many times that it has stopped sounding like advice and started sounding like a tic. Yet when I open a new server for someone, the backup situation is usually one of two things: nothing, or a nightly &lt;code&gt;tar&lt;/code&gt; job that has been filling a disk since 2023 and has never been opened.&lt;/p&gt;

&lt;p&gt;If you run a small VPS and want a restic backup setup that you can set up in an evening and trust afterwards, this is the one I'd use. I'll cover what restic is doing under the hood in just enough detail to make the commands make sense, then the backup script, the retention policy, a Postgres dump piped straight in, and the part everyone skips: checking and restoring.&lt;/p&gt;

&lt;p&gt;I should be upfront about scope. This is for small self-hosted setups, one or a few servers, data measured in gigabytes. If you run a database cluster with point-in-time recovery requirements, a file-level tool is the wrong layer for the database and you should look at WAL archiving instead. For everything else on the box (uploads, configs, compose files, a database dump), restic is a calm choice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why restic suits a small VPS
&lt;/h2&gt;

&lt;p&gt;Restic stores your data as encrypted, deduplicated snapshots in a repository. Deduplication means a second backup of mostly unchanged files transfers and stores very little, which is why you can run it nightly without watching your storage bill climb. The repository can sit on a local disk, an SFTP server, or an object store, and the &lt;a href="https://restic.readthedocs.io/en/stable/030_preparing_a_new_repo.html" rel="noopener noreferrer"&gt;restic documentation on preparing a repository&lt;/a&gt; lists the supported backends.&lt;/p&gt;

&lt;p&gt;I put the repository somewhere that is not the server being backed up. That sounds obvious, and I have still seen backups stored on the same disk as the data. A disk failure, a ransomware incident or a fat-fingered &lt;code&gt;rm -rf&lt;/code&gt; takes both. A storage box or object bucket from a different provider costs a few dollars a month and removes that whole category of failure.&lt;/p&gt;

&lt;p&gt;Initialise once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;RESTIC_REPOSITORY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"sftp:backup@storagebox.example.com:/restic/myserver"&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;RESTIC_PASSWORD_FILE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"/etc/restic/password"&lt;/span&gt;

restic init
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep the password file readable by root only, and keep a second copy of the password somewhere that is not that server, such as your password manager. Restic encrypts the repository, so losing the password means losing every backup. I have not had to test how final that is, and I don't plan to.&lt;/p&gt;

&lt;h2&gt;
  
  
  The backup script
&lt;/h2&gt;

&lt;p&gt;Here is the script I start from. It is plain Bash on purpose.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail

&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;RESTIC_REPOSITORY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"sftp:backup@storagebox.example.com:/restic/myserver"&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;RESTIC_PASSWORD_FILE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"/etc/restic/password"&lt;/span&gt;

&lt;span class="c"&gt;# 1. Files: configs, uploads, compose projects&lt;/span&gt;
restic backup &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--tag&lt;/span&gt; files &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--one-file-system&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--exclude-file&lt;/span&gt; /etc/restic/excludes.txt &lt;span class="se"&gt;\&lt;/span&gt;
  /etc /srv /home/deploy

&lt;span class="c"&gt;# 2. Database: dump straight into the repository, no temp file&lt;/span&gt;
restic backup &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--tag&lt;/span&gt; postgres &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--stdin-from-command&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--stdin-filename&lt;/span&gt; myapp.sql &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="nb"&gt;sudo&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; postgres pg_dump &lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;plain myapp

&lt;span class="c"&gt;# 3. Retention&lt;/span&gt;
restic forget &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--tag&lt;/span&gt; files &lt;span class="nt"&gt;--keep-daily&lt;/span&gt; 7 &lt;span class="nt"&gt;--keep-weekly&lt;/span&gt; 4 &lt;span class="nt"&gt;--keep-monthly&lt;/span&gt; 6 &lt;span class="nt"&gt;--prune&lt;/span&gt;
restic forget &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--tag&lt;/span&gt; postgres &lt;span class="nt"&gt;--keep-daily&lt;/span&gt; 14 &lt;span class="nt"&gt;--keep-weekly&lt;/span&gt; 8 &lt;span class="nt"&gt;--keep-monthly&lt;/span&gt; 12 &lt;span class="nt"&gt;--prune&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few choices in there are worth explaining.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;--one-file-system&lt;/code&gt; flag. The &lt;a href="https://restic.readthedocs.io/en/stable/040_backup.html" rel="noopener noreferrer"&gt;restic backup documentation&lt;/a&gt; says it stops restic from crossing filesystem boundaries and subvolumes. On a VPS that keeps it from wandering into mounted volumes, network shares or &lt;code&gt;/proc&lt;/code&gt;-style virtual filesystems you never meant to back up. If you do have a second volume with data you want, list its mount point explicitly as another path.&lt;/p&gt;

&lt;p&gt;The exclude file. Things like &lt;code&gt;node_modules&lt;/code&gt;, build caches, &lt;code&gt;*.log&lt;/code&gt;, and Docker's own storage directory should stay out. You back up your compose files and volumes, not &lt;code&gt;/var/lib/docker/overlay2&lt;/code&gt;. The same docs page describes &lt;code&gt;--exclude-file&lt;/code&gt; as a file listing items to skip, one pattern per line.&lt;/p&gt;

&lt;p&gt;The Postgres dump. The same documentation explains that &lt;code&gt;--stdin-from-command&lt;/code&gt; makes restic run a command and store its output, and that you must put &lt;code&gt;--&lt;/code&gt; before the command so restic doesn't parse its arguments. That removes the temp file I used to write to disk, which was always a second copy of the database sitting around waiting to be forgotten. My own bit of caution here: a plain-format dump of a large database is slow to restore. For anything past a few gigabytes I'd switch to &lt;code&gt;pg_dump --format=custom&lt;/code&gt; and restore with &lt;code&gt;pg_restore&lt;/code&gt;, which also lets you restore a single table.&lt;/p&gt;

&lt;p&gt;Tags. I tag file snapshots and database snapshots differently, then apply different retention to each. The docs mention tags as a way to attach identifying information and to select snapshots in &lt;code&gt;forget&lt;/code&gt;, and I use them for exactly that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Retention without surprises
&lt;/h2&gt;

&lt;p&gt;Retention is where I've seen people shoot themselves. The &lt;a href="https://restic.readthedocs.io/en/stable/060_forget.html" rel="noopener noreferrer"&gt;restic forget documentation&lt;/a&gt; explains the policy flags: &lt;code&gt;--keep-daily n&lt;/code&gt; keeps the most recent snapshot for each of the last n days that have snapshots, with matching weekly and monthly flags. It also notes that &lt;code&gt;forget --prune&lt;/code&gt; removes the data that was referenced only by the snapshots you dropped, which can take a while on a large repository.&lt;/p&gt;

&lt;p&gt;Two cautions from that documentation shape how I use it. First, a policy applies to snapshots grouped by host and paths by default, so if you change your backup paths later, you create a new group, and the old one is no longer being pruned by the same rules. I run &lt;code&gt;restic snapshots&lt;/code&gt; after any path change and look at what groups exist. Second, run &lt;code&gt;forget&lt;/code&gt; with &lt;code&gt;--dry-run&lt;/code&gt; the first time. Reading the list of what would be removed costs nothing.&lt;/p&gt;

&lt;p&gt;My defaults are a week of dailies, a month of weeklies and half a year of monthlies for files, and a bit longer for database dumps, since those are the thing clients ask for after a mistake. Adjust for what your data is worth and how long you'd take to notice a problem. If a bug quietly corrupts data and you notice a month later, a seven-day retention doesn't help you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scheduling it
&lt;/h2&gt;

&lt;p&gt;I use a systemd timer instead of cron, mainly because a failed run shows up in &lt;code&gt;systemctl status&lt;/code&gt; and the journal without extra work.&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="c"&gt;# /etc/systemd/system/restic-backup.service
&lt;/span&gt;&lt;span class="nn"&gt;[Unit]&lt;/span&gt;
&lt;span class="py"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;Restic backup&lt;/span&gt;
&lt;span class="py"&gt;After&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;network-online.target&lt;/span&gt;

&lt;span class="nn"&gt;[Service]&lt;/span&gt;
&lt;span class="py"&gt;Type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;oneshot&lt;/span&gt;
&lt;span class="py"&gt;ExecStart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/usr/local/bin/restic-backup.sh&lt;/span&gt;

&lt;span class="c"&gt;# /etc/systemd/system/restic-backup.timer
&lt;/span&gt;&lt;span class="nn"&gt;[Unit]&lt;/span&gt;
&lt;span class="py"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;Nightly restic backup&lt;/span&gt;

&lt;span class="nn"&gt;[Timer]&lt;/span&gt;
&lt;span class="py"&gt;OnCalendar&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;*-*-* 03:15:00&lt;/span&gt;
&lt;span class="py"&gt;RandomizedDelaySec&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;15min&lt;/span&gt;
&lt;span class="py"&gt;Persistent&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;

&lt;span class="nn"&gt;[Install]&lt;/span&gt;
&lt;span class="py"&gt;WantedBy&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;timers.target&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Enable it with &lt;code&gt;systemctl enable --now restic-backup.timer&lt;/code&gt;. Cron works just as well if that's what you already have. What matters is that something tells you when it fails. I add a final line to the script that pings a monitoring URL on success, so a missed backup raises an alert through silence. Dead man's switches are underrated.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check the repository, then restore something
&lt;/h2&gt;

&lt;p&gt;Two commands separate a backup from a hope. The first is &lt;code&gt;restic check&lt;/code&gt;, which verifies the repository structure. The &lt;a href="https://restic.readthedocs.io/en/stable/045_working_with_repos.html" rel="noopener noreferrer"&gt;documentation on working with repositories&lt;/a&gt; describes &lt;code&gt;--read-data-subset&lt;/code&gt;, which checks a portion of the pack files per run, for example &lt;code&gt;--read-data-subset=1/5&lt;/code&gt; on Monday, &lt;code&gt;2/5&lt;/code&gt; on Tuesday and so on, so each night reads a fifth of the data and a full pass completes in five runs. That is a sane compromise when reading everything would cost bandwidth.&lt;/p&gt;

&lt;p&gt;The second is a restore test, and I'd do it monthly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;restic restore latest &lt;span class="nt"&gt;--tag&lt;/span&gt; files &lt;span class="nt"&gt;--target&lt;/span&gt; /tmp/restore-test &lt;span class="nt"&gt;--include&lt;/span&gt; /etc/nginx
restic dump latest myapp.sql &lt;span class="nt"&gt;--tag&lt;/span&gt; postgres | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; 20
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Restore into a scratch directory, open the files, load the dump into a throwaway database, and check a row count against production. Write down how long it took. That number is your real recovery time, and it's the one you'll want in your head when something breaks at night.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do this tonight
&lt;/h2&gt;

&lt;p&gt;Pick one server. Create a repository on storage that lives somewhere else, run &lt;code&gt;restic init&lt;/code&gt;, and run the script above by hand with the file backup only. Then restore one directory to &lt;code&gt;/tmp&lt;/code&gt; and compare it with the original using &lt;code&gt;diff -r&lt;/code&gt;. Once that works, add the database line, then the timer. If you want a second pair of eyes on a setup like this for a client system, I take on infrastructure work, and you can find more about it on &lt;a href="https://abrarqasim.com" rel="noopener noreferrer"&gt;my portfolio&lt;/a&gt;. If your data lives in Postgres, my post on &lt;a href="https://abrarqasim.com/blog/postgres-encryption-at-rest-luks-vs-pg-vault-tde-what-stays-plaintext/" rel="noopener noreferrer"&gt;what stays plaintext under encryption at rest&lt;/a&gt; is a useful companion, because backups need the same care as the live database.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://abrarqasim.com/blog/restic-backup-small-vps-postgres-dump-retention-restore-test/" rel="noopener noreferrer"&gt;abrarqasim.com&lt;/a&gt;. I write there about React, PHP, Rust, Go and the AI tooling around them.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>restic</category>
      <category>backup</category>
      <category>postgres</category>
      <category>selfhosting</category>
    </item>
    <item>
      <title>WhatsApp Business API Automation in n8n: Start With the Window</title>
      <dc:creator>Qasim Parray</dc:creator>
      <pubDate>Wed, 14 Oct 2026 16:38:38 +0000</pubDate>
      <link>https://dev.to/abyzgenic/whatsapp-business-api-automation-in-n8n-start-with-the-window-54ae</link>
      <guid>https://dev.to/abyzgenic/whatsapp-business-api-automation-in-n8n-start-with-the-window-54ae</guid>
      <description>&lt;p&gt;Every few weeks a small business owner tells me the same thing: "We get hundreds of WhatsApp messages and we answer them by hand. Can you automate it?" The honest answer is yes, partly, and the part you automate matters more than the tool you pick. I have watched more than one WhatsApp bot idea die because it was designed as a chatbot first and a messaging channel second.&lt;/p&gt;

&lt;p&gt;WhatsApp business API automation has rules that web chat doesn't. There is a clock on every conversation, there is pricing per delivered message, and there are template messages that need approval before you can send them cold. If you wire up a flow in n8n without those in your head, you will build something that works on your own phone and falls over on the third real customer.&lt;/p&gt;

&lt;p&gt;This is the order I'd tackle it in now: rules first, then the workflow, then where a language model fits, and where it shouldn't. I'm describing the shape here, not a finished product, and I'll flag the places where I'm relying on documentation I couldn't fully read.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start from the 24-hour window
&lt;/h2&gt;

&lt;p&gt;The single most useful fact about the platform is the customer service window. According to the &lt;a href="https://business.whatsapp.com/products/platform-pricing" rel="noopener noreferrer"&gt;WhatsApp Business Platform pricing page&lt;/a&gt;, when a user messages a business it opens a 24-hour window in which the business can reply with service messages at no charge, and the window resets with each new user message.&lt;/p&gt;

&lt;p&gt;That sentence should drive your design. Inside the window you can send free-form replies, which is where an AI draft or an automated answer is natural. Outside it, you can only start a conversation with an approved template message, and that category of message is billed.&lt;/p&gt;

&lt;p&gt;So the first question for any automation is: does this flow answer people, or does it start conversations? Answering is cheap and conversational. Starting conversations is a marketing and compliance decision that deserves its own planning. I build the answering flow first because it has a clear boundary, and the customer already asked for the interaction.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you actually pay for
&lt;/h2&gt;

&lt;p&gt;I'd rather you read the pricing page yourself than trust my summary, but the structure is worth knowing before you promise a client a monthly figure. The same page says businesses are charged per message delivered (not sent), rates depend on the destination market and the message category, and there are four categories: marketing, authentication, utility and service. It also says service messages are not charged, and that messages in the three days after a customer arrives through an ad that clicks to WhatsApp are free.&lt;/p&gt;

&lt;p&gt;Pricing models like this have changed more than once, and the page I read is the one that applies today, not necessarily next quarter. When I quote automation work I give the client a range, tell them where to look up the current rates for their country, and keep the platform fees on a separate line from my own fee. Rolling them together makes my quote look wrong every time Meta changes a number.&lt;/p&gt;

&lt;h2&gt;
  
  
  A small n8n shape that works
&lt;/h2&gt;

&lt;p&gt;The pattern I use is boring on purpose. A webhook receives the incoming message. A short chain of nodes decides what kind of message it is. One branch answers common questions from a fixed source, and another branch hands the conversation to a human.&lt;/p&gt;

&lt;p&gt;n8n has a built-in &lt;a href="https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.whatsapp/" rel="noopener noreferrer"&gt;WhatsApp Business Cloud node&lt;/a&gt; whose documented operations include sending a message, sending a template, a send-and-wait-for-response operation, and uploading, downloading and deleting media. For receiving messages you pair it with a webhook. Meta's &lt;a href="https://developers.facebook.com/docs/whatsapp/cloud-api/guides/set-up-webhooks" rel="noopener noreferrer"&gt;webhook guide&lt;/a&gt; is where the handshake and payload format live. I can't reproduce those details with confidence because that page renders in the browser and I'd rather you read it than copy my paraphrase.&lt;/p&gt;

&lt;p&gt;Two things I always add, because webhook senders in general retry and attackers in general guess URLs.&lt;/p&gt;

&lt;p&gt;First, verify that the request came from Meta. Incoming requests carry a signature header you can check against your app secret. In an n8n Code node the check looks roughly like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;crypto&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;secret&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;$env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;WHATSAPP_APP_SECRET&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;received&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;$json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;x-hub-signature-256&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;expected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sha256=&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createHmac&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sha256&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;$json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rawBody&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hex&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;received&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
  &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timingSafeEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;received&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;bad signature&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;$input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The hard part is &lt;code&gt;rawBody&lt;/code&gt;. The signature is computed over the exact bytes Meta sent, so a node that parses the JSON and re-serialises it gives you a different string and a check that always fails. Turn on the raw body option in the Webhook node settings and test with a real message, not a hand-made curl request.&lt;/p&gt;

&lt;p&gt;Second, deduplicate. If your workflow is slow and the sender decides it hasn't heard back, you can see the same message twice. I store each message ID in a small Postgres table and skip anything already seen:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;wa_seen&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;CONFLICT&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;DO&lt;/span&gt; &lt;span class="k"&gt;NOTHING&lt;/span&gt;
&lt;span class="n"&gt;RETURNING&lt;/span&gt; &lt;span class="n"&gt;message_id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the insert returns no row, the flow stops. That one query is what stops a customer getting the same reply twice. I wrote about a nastier n8n failure, where a quiet bug cost a client days, in &lt;a href="https://abrarqasim.com/blog/n8n-workflow-automation-the-bug-that-cost-a-client-eleven-days/" rel="noopener noreferrer"&gt;my n8n workflow post&lt;/a&gt;. The lesson there applies here: log every message ID and every branch decision, because you will need that log.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where a language model fits
&lt;/h2&gt;

&lt;p&gt;A model is good at one WhatsApp job: reading a messy customer message and drafting a reply from a source you control, such as an FAQ page or a price list. It is bad at deciding things the business is accountable for, like refunds, delivery promises or anything legal.&lt;/p&gt;

&lt;p&gt;My rule is that the model drafts and a person sends, until the numbers say otherwise. The n8n docs describe a send-and-wait operation that pauses the workflow until a person approves or provides input, and the same page notes the node can serve as a human review step for AI agent tool calls. That fits this pattern directly: the draft goes to a staff member on WhatsApp, they tap approve or edit it, and only then does the customer see it.&lt;/p&gt;

&lt;p&gt;After a few weeks of watching approvals, you'll know which question types are approved almost untouched. Those are the ones you can move to automatic replies. Everything else stays in review. If the idea of judging whether an agent is good enough makes you uneasy, I wrote up a method in &lt;a href="https://abrarqasim.com/blog/ai-agent-evaluation-grade-the-database-not-the-reply/" rel="noopener noreferrer"&gt;grading the database, not the reply&lt;/a&gt;, and it carries over to chat flows.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to leave out of version one
&lt;/h2&gt;

&lt;p&gt;Leave out cold outreach. Leave out the model answering anything about money or health. Leave out clever intent classifiers until you have a few hundred real messages to look at. Version one should do three plain things: receive, answer from a fixed source inside the window, and hand off to a human with the full thread attached.&lt;/p&gt;

&lt;p&gt;I'd also leave out voice notes and images at first. They are common on WhatsApp, and they'll arrive anyway, so your flow needs a polite fallback that says a person will look at it soon. Media handling is a good second project, once the text flow is quiet.&lt;/p&gt;

&lt;p&gt;When not to automate at all: if the business gets thirty messages a day and one person answers them well, a bot costs more attention than it saves. Automation pays back when volume is high and questions repeat.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build this first
&lt;/h2&gt;

&lt;p&gt;This week, take one real client or your own business and export fifty recent WhatsApp conversations. Sort the first messages into groups by what the customer wanted. If one or two groups cover most of the volume, you have a scoped automation. Write the three answers those groups need, put them in a table, and build the receive-verify-deduplicate-answer chain in n8n against Meta's test number. Show a human the drafts before anything is sent. If you want help turning that into a production flow for your own business, my other automation projects are listed on &lt;a href="https://abrarqasim.com" rel="noopener noreferrer"&gt;my portfolio&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://abrarqasim.com/blog/whatsapp-business-api-automation-n8n-24-hour-window-first/" rel="noopener noreferrer"&gt;abrarqasim.com&lt;/a&gt;. I write there about React, PHP, Rust, Go and the AI tooling around them.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>whatsapp</category>
      <category>n8n</category>
      <category>automation</category>
      <category>aiautomation</category>
    </item>
    <item>
      <title>Freelance Contract Template: Six Questions I Settle Before I Sign</title>
      <dc:creator>Qasim Parray</dc:creator>
      <pubDate>Wed, 14 Oct 2026 08:38:38 +0000</pubDate>
      <link>https://dev.to/abyzgenic/freelance-contract-template-six-questions-i-settle-before-i-sign-6ke</link>
      <guid>https://dev.to/abyzgenic/freelance-contract-template-six-questions-i-settle-before-i-sign-6ke</guid>
      <description>&lt;p&gt;Here is a confession from a developer who has signed more than one contract he did not fully read. The first time a client disputed an invoice with me, I opened my "contract" and found a two-line email thread and a Google Doc titled "Scope v3 FINAL". Neither said when I was supposed to be paid. Neither said who owned the code. I got paid eventually, but only because the client was decent, and that is not a contract, it is luck.&lt;/p&gt;

&lt;p&gt;If you freelance in web development, you need a contract template you actually trust, and you need it before the first discovery call turns into a project. I'm not a lawyer, and nothing here is legal advice. What follows is the checklist I run through before I sign anything, with the clauses I've seen cause the most pain, and a few pointers to primary sources so you can check my claims instead of taking my word for them.&lt;/p&gt;

&lt;p&gt;A freelance contract template does not need to be long. Mine is about two pages. It needs to answer six questions in plain English, and the rest of this post walks through them.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a short contract has to settle
&lt;/h2&gt;

&lt;p&gt;A good contract is a list of decisions you made while you were both still friendly. Every clause exists because, at some point, two reasonable people remembered a conversation differently.&lt;/p&gt;

&lt;p&gt;The six questions I want answered in writing are these. What exactly am I building, and what happens when the request changes? When and how do I get paid? Who owns the result, and when does ownership transfer? What happens if either of us stops? Who is responsible if something breaks after launch? And, for work in some places, does the law already require something I forgot?&lt;/p&gt;

&lt;p&gt;If you want a version of the first question that wins you the job rather than just protecting you, I wrote about the proposal side in &lt;a href="https://abrarqasim.com/blog/web-development-proposal-template-the-section-that-wins-the-job/" rel="noopener noreferrer"&gt;my web development proposal template post&lt;/a&gt;. The contract is the quieter sibling: it only matters when something goes sideways.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scope and the change clause
&lt;/h2&gt;

&lt;p&gt;Scope descriptions fail when they describe features instead of outcomes and boundaries. "A contact form" is a feature. "A contact form that sends to one address, with a spam filter, no file uploads, no CRM integration" is a scope.&lt;/p&gt;

&lt;p&gt;I list deliverables, then I list what is explicitly excluded, then I add a change clause. The change clause is short:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Work outside the deliverables listed in section 2 is a change request.
I will estimate it in writing. Work on a change request starts after
you approve the estimate by reply email. Approved changes are billed at
the rate in section 3 and may move the delivery date.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That paragraph does more for scope creep than any amount of firmness in a meeting. It turns "can you just also..." into a question with a price attached, and it makes me the person offering an estimate rather than the person saying no. I went through the human side of that problem in &lt;a href="https://abrarqasim.com/blog/how-to-handle-scope-creep-the-quick-one-that-cost-me-a-week/" rel="noopener noreferrer"&gt;my scope creep post&lt;/a&gt;, and the short version is that a one-line email estimate beats a long conversation every time.&lt;/p&gt;

&lt;p&gt;One more thing about scope: put the number of revision rounds in the contract. "Unlimited revisions" is a sentence that sounds friendly and ends careers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Payment terms that survive a slow client
&lt;/h2&gt;

&lt;p&gt;Payment clauses are where most freelancers are too polite. Mine has five parts, and I'd keep all five.&lt;/p&gt;

&lt;p&gt;A deposit before work starts. I take a third on signing for projects under a few weeks, and I never begin without it. The deposit is not about money, it is a cheap test of whether the client can send a payment at all.&lt;/p&gt;

&lt;p&gt;Milestone invoices tied to deliverables, not to calendar dates. If I invoice on "the 15th" and the client has not reviewed anything, I'm invoicing for a promise.&lt;/p&gt;

&lt;p&gt;Net terms in days, stated as a number. "Payable within 14 days of invoice date" beats "promptly" because you can count to 14.&lt;/p&gt;

&lt;p&gt;A late fee or a pause clause. I use a pause clause: if an invoice is more than seven days overdue, work pauses and the delivery dates move by the length of the pause. It's gentler than interest and more effective, since clients rarely care about a 1.5% fee but do care about their launch date.&lt;/p&gt;

&lt;p&gt;Currency and fees. If you work across borders, say who pays the bank and platform charges. I've lost a surprising amount of an invoice to transfer fees nobody mentioned.&lt;/p&gt;

&lt;p&gt;For how I decide the numbers that go into those clauses, see &lt;a href="https://abrarqasim.com/blog/how-i-actually-price-freelance-web-development-work/" rel="noopener noreferrer"&gt;how I price freelance web development work&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who owns the code
&lt;/h2&gt;

&lt;p&gt;This is the clause people copy from the internet and get wrong, so I'll go slowly.&lt;/p&gt;

&lt;p&gt;Many templates say the work is "work made for hire". That phrase has a specific meaning in US law. The statute's definition, in &lt;a href="https://www.copyright.gov/title17/92chap1.html" rel="noopener noreferrer"&gt;17 U.S.C. section 101&lt;/a&gt;, covers works made by an employee within the scope of employment, and a short list of commissioned categories (a contribution to a collective work, a translation, a compilation, an instructional text and a few others) where the parties agreed in a signed writing. Custom website or application code does not obviously sit in that list. The US Copyright Office's &lt;a href="https://www.copyright.gov/circs/circ30.pdf" rel="noopener noreferrer"&gt;Circular 30 on works made for hire&lt;/a&gt; is a readable explanation, and it is worth five minutes before you paste a boilerplate sentence into your agreement.&lt;/p&gt;

&lt;p&gt;What I use instead is an assignment clause that transfers ownership on full payment, plus a licence for anything I bring with me:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;On receipt of full payment, I assign to you the copyright in the
deliverables created specifically for this project. I keep ownership of
my pre-existing tools, libraries and templates, and grant you a
perpetual, non-exclusive licence to use them as part of the deliverables.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details matter. "On full payment" ties ownership to the thing you care about. And the carve-out for pre-existing tools means you can reuse your own deploy scripts and component library on the next job without being accused of selling something twice.&lt;/p&gt;

&lt;p&gt;Other countries have different rules, and whether a clause like that holds up depends on where you and your client live. If the project is large, pay a lawyer for an hour to read your template once. That hour is cheap compared with one dispute.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ending the project early
&lt;/h2&gt;

&lt;p&gt;Every contract needs an exit. Mine allows either side to end the agreement with seven days written notice. On termination, the client pays for work completed to that point, plus a kill fee, which I set at a flat percentage of the unbilled remainder for fixed-price projects. The kill fee isn't a punishment. It reflects that I turned down other work to hold the slot.&lt;/p&gt;

&lt;p&gt;I also add a line that deliverables, source code and credentials are handed over only after the final invoice is settled. Clients occasionally see that as aggressive, and I explain it as the same rule that governs ownership.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the law may already require
&lt;/h2&gt;

&lt;p&gt;Some places now require a written contract for freelance work. New York State is one example: its Department of Labor &lt;a href="https://dol.ny.gov/freelance-isnt-free-act" rel="noopener noreferrer"&gt;page on the Freelance Isn't Free Act&lt;/a&gt; says the act added Article 44-A to the General Business Law in August 2024, with contract requirements and a formal enforcement process, and that the department published a model contract. I haven't read the whole statute closely, and your own jurisdiction might have something similar or nothing at all. Searching your state or country plus "freelance written contract law" takes five minutes and might turn up a requirement you didn't know about.&lt;/p&gt;

&lt;p&gt;Treat the model contract from a government site as a free checklist even if it doesn't apply to you. Compare it against yours and see what's missing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do this before your next call
&lt;/h2&gt;

&lt;p&gt;Open your last signed agreement, or the email thread that passed for one. Hold it up against the six questions near the top of this post. Where you find a gap, write the missing clause in two sentences, and paste it into a template file you will actually reuse. Then send the next client that template before the kickoff call, not after. If you want to see how I structure the work that comes after the contract, the rest of my projects are on &lt;a href="https://abrarqasim.com" rel="noopener noreferrer"&gt;my portfolio&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://abrarqasim.com/blog/freelance-contract-template-six-questions-before-i-sign/" rel="noopener noreferrer"&gt;abrarqasim.com&lt;/a&gt;. I write there about React, PHP, Rust, Go and the AI tooling around them.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>freelance</category>
      <category>contracts</category>
      <category>webdev</category>
      <category>clientwork</category>
    </item>
    <item>
      <title>Zero Downtime Deployment on One VPS With Compose and Caddy</title>
      <dc:creator>Qasim Parray</dc:creator>
      <pubDate>Tue, 13 Oct 2026 16:38:36 +0000</pubDate>
      <link>https://dev.to/abyzgenic/zero-downtime-deployment-on-one-vps-with-compose-and-caddy-21e2</link>
      <guid>https://dev.to/abyzgenic/zero-downtime-deployment-on-one-vps-with-compose-and-caddy-21e2</guid>
      <description>&lt;p&gt;For a long time my deploy process on small client servers was one command and a prayer. I would SSH in, run &lt;code&gt;docker compose up -d --build&lt;/code&gt;, watch the logs scroll, and tell myself that a few seconds of broken pages was the price of shipping. Then a client opened their own site mid-deploy, got a bare 502 from the proxy, and sent me a screenshot with no text at all. The silence was worse than an angry message.&lt;/p&gt;

&lt;p&gt;Zero downtime deployment sounds like something you need Kubernetes for. You don't. On a single VPS you can get to the point where a deploy is invisible to visitors using two things you probably already run: Docker Compose and a reverse proxy that does health checks. I use &lt;a href="https://abrarqasim.com/blog/caddy-vs-nginx-small-vps-two-line-config-and-where-nginx-still-wins/" rel="noopener noreferrer"&gt;Caddy&lt;/a&gt; for this, and the setup below is what I now put on every new box.&lt;/p&gt;

&lt;p&gt;I should say up front what this is not. It isn't a database migration strategy, and it won't save you from a deploy that ships a broken build with a healthy-looking &lt;code&gt;/health&lt;/code&gt; route. It handles the boring, common case: replacing the app container without dropping requests.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a plain compose up drops requests
&lt;/h2&gt;

&lt;p&gt;When you change an image and run &lt;code&gt;docker compose up -d&lt;/code&gt;, Compose stops the old container and starts a new one in its place. There is a gap. The old process gets SIGTERM, the new process boots, loads its config, warms its connections, and only then starts listening. Anything that arrives between those two moments hits a closed port.&lt;/p&gt;

&lt;p&gt;The proxy in front of it makes this visible. Caddy tries to connect to &lt;code&gt;web:3000&lt;/code&gt;, gets refused, and returns a 502. Nothing is wrong with Caddy. It did exactly what it was told.&lt;/p&gt;

&lt;p&gt;The fix has to cover two separate problems. First, the old container must stay alive until the new one is ready. Second, the proxy must know which containers are ready, and send traffic only to those. Most "zero downtime" tutorials solve the first and forget the second, which is how you end up with two containers running and requests still landing on the one that is halfway through booting.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with a health check that means something
&lt;/h2&gt;

&lt;p&gt;Compose can wait for a container to be healthy before it considers the deploy finished. The &lt;a href="https://docs.docker.com/reference/compose-file/services/" rel="noopener noreferrer"&gt;Compose service reference&lt;/a&gt; documents &lt;code&gt;healthcheck&lt;/code&gt; with &lt;code&gt;interval&lt;/code&gt;, &lt;code&gt;timeout&lt;/code&gt;, &lt;code&gt;retries&lt;/code&gt;, &lt;code&gt;start_period&lt;/code&gt; and &lt;code&gt;start_interval&lt;/code&gt;. That last one only exists from Compose 2.20.2, so check your version on old servers before you rely on it.&lt;/p&gt;

&lt;p&gt;Here is the service I start from:&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;web&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;registry.example.com/myapp:${TAG}&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;stop_grace_period&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;30s&lt;/span&gt;
    &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;test&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;CMD"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;wget"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-qO-"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://localhost:3000/health"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5s&lt;/span&gt;
      &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;3s&lt;/span&gt;
      &lt;span class="na"&gt;retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;
      &lt;span class="na"&gt;start_period&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;20s&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;internal&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

  &lt;span class="na"&gt;caddy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;caddy:2&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;80:80"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;443:443"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./Caddyfile:/etc/caddy/Caddyfile&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;caddy_data:/data&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;internal&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;internal&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;

&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;caddy_data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details matter here. The &lt;code&gt;web&lt;/code&gt; service publishes no host port, so two copies of it can run side by side without fighting over a port. And &lt;code&gt;stop_grace_period&lt;/code&gt; gives the old container time to finish in-flight requests after it receives SIGTERM, instead of Docker's default ten seconds.&lt;/p&gt;

&lt;p&gt;I wrote a longer rant about what &lt;code&gt;depends_on&lt;/code&gt; actually waits for in my &lt;a href="https://abrarqasim.com/blog/docker-compose-healthcheck-the-depends-on-that-only-waited-for-start/" rel="noopener noreferrer"&gt;post on Compose health checks&lt;/a&gt;. The short version: a container that has started is not a container that can serve a request, and your &lt;code&gt;/health&lt;/code&gt; route needs to check something real. A route that returns 200 without touching the database is a smoke detector with the battery taken out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Let Caddy find both copies
&lt;/h2&gt;

&lt;p&gt;Compose gives every service a DNS name on the network. If &lt;code&gt;web&lt;/code&gt; has two running containers, a lookup for &lt;code&gt;web&lt;/code&gt; returns two addresses. Caddy's &lt;code&gt;reverse_proxy&lt;/code&gt; can use that directly with the &lt;code&gt;dynamic a&lt;/code&gt; upstream source, which the &lt;a href="https://caddyserver.com/docs/caddyfile/directives/reverse_proxy" rel="noopener noreferrer"&gt;reverse_proxy docs&lt;/a&gt; describe as querying a domain name and refreshing the cached results on an interval (the default is one minute, which is far too slow for us).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;example.com {
    reverse_proxy {
        dynamic a web 3000 {
            refresh 2s
        }
        lb_policy round_robin
        lb_try_duration 5s
        lb_try_interval 250ms

        health_uri /health
        health_interval 2s
        health_fails 1
        health_passes 2
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pieces do different jobs. &lt;code&gt;refresh 2s&lt;/code&gt; makes Caddy notice new containers quickly. The active health check (&lt;code&gt;health_uri&lt;/code&gt; plus &lt;code&gt;health_interval&lt;/code&gt;) marks an upstream as down when &lt;code&gt;/health&lt;/code&gt; fails, so a container that is still booting gets no traffic. And &lt;code&gt;lb_try_duration&lt;/code&gt; tells Caddy to keep trying to pick a healthy backend for up to five seconds instead of failing immediately. The docs are clear that retries are off by default, so this line is doing real work. A visitor who arrives in the middle of the swap waits a moment rather than seeing an error page.&lt;/p&gt;

&lt;p&gt;I'm not fully sure &lt;code&gt;refresh 2s&lt;/code&gt; is the right number for every setup. Two seconds worked on the small boxes I run. On a busy server with many services, more DNS lookups might matter, and I haven't measured that.&lt;/p&gt;

&lt;h2&gt;
  
  
  The deploy script
&lt;/h2&gt;

&lt;p&gt;With that in place, the deploy is three steps: start a second copy, wait until it is healthy, then remove the old one.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail

&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;TAG&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
docker compose pull web

&lt;span class="c"&gt;# Remember which containers are running the old version&lt;/span&gt;
&lt;span class="nv"&gt;OLD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;docker compose ps &lt;span class="nt"&gt;-q&lt;/span&gt; web&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;# Add a second copy built from the new tag, keep the old one untouched&lt;/span&gt;
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--no-deps&lt;/span&gt; &lt;span class="nt"&gt;--no-recreate&lt;/span&gt; &lt;span class="nt"&gt;--scale&lt;/span&gt; &lt;span class="nv"&gt;web&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;2 &lt;span class="nt"&gt;--wait&lt;/span&gt; &lt;span class="nt"&gt;--wait-timeout&lt;/span&gt; 90 web

&lt;span class="c"&gt;# New copy is healthy, so retire the old ones&lt;/span&gt;
docker stop &lt;span class="nt"&gt;--time&lt;/span&gt; 30 &lt;span class="nv"&gt;$OLD&lt;/span&gt;
docker &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nv"&gt;$OLD&lt;/span&gt;

&lt;span class="c"&gt;# Let Compose settle back to a single replica&lt;/span&gt;
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--no-deps&lt;/span&gt; &lt;span class="nt"&gt;--no-recreate&lt;/span&gt; &lt;span class="nt"&gt;--scale&lt;/span&gt; &lt;span class="nv"&gt;web&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 web
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The order of those lines is the whole trick, so here is why each one is there.&lt;/p&gt;

&lt;p&gt;Recording &lt;code&gt;OLD&lt;/code&gt; before anything else matters because Compose, not you, decides which container to remove when you scale down. If you scale to one and let it choose, there is no promise it picks the old one. Writing the IDs down first means you stop exactly the containers you meant to. I learned this by reading the output of &lt;code&gt;docker ps&lt;/code&gt; after a test deploy and noticing the container with the newer image was the one that had disappeared. That was a staging box, which is the only reason it was funny.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;--no-recreate&lt;/code&gt; flag is what keeps the old container alive. The &lt;a href="https://docs.docker.com/reference/cli/docker/compose/up/" rel="noopener noreferrer"&gt;docker compose up reference&lt;/a&gt; says it stops Compose from recreating containers that already exist. Because &lt;code&gt;TAG&lt;/code&gt; has changed, the extra replica that gets created is built from the new image, and the existing one keeps serving until we stop it.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;--wait&lt;/code&gt; flag is the gate. According to the same reference it waits for services to be running or healthy and implies detached mode, with &lt;code&gt;--wait-timeout&lt;/code&gt; as the upper bound. If the new container never becomes healthy, the command fails, &lt;code&gt;set -e&lt;/code&gt; ends the script, and the old container is still serving. A failed deploy that changes nothing is a good failure. You can also roll back by exporting the previous tag and running the script again, since the script has no memory of what came before.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prove it with a loop
&lt;/h2&gt;

&lt;p&gt;I don't trust a zero downtime claim I haven't watched. Before the first real deploy, run a dumb load loop in one terminal and the deploy in another:&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;while &lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"%{http_code} %{time_total}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; https://example.com/
  &lt;span class="nb"&gt;sleep &lt;/span&gt;0.2
&lt;span class="k"&gt;done&lt;/span&gt; | &lt;span class="nb"&gt;tee &lt;/span&gt;deploy-check.log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Afterwards, &lt;code&gt;grep -v '^200' deploy-check.log&lt;/code&gt; shows every request that didn't succeed. Look at the time column too. During the overlap you may see a few requests that take a second or two instead of a few milliseconds. That is &lt;code&gt;lb_try_duration&lt;/code&gt; quietly doing its job, and it is the difference between a slow page and a broken one. If you see 502s, check the three usual suspects in order: the health route returns 200 before the app can really serve, &lt;code&gt;refresh&lt;/code&gt; is still at its default minute, or &lt;code&gt;lb_try_duration&lt;/code&gt; is missing.&lt;/p&gt;

&lt;p&gt;I also keep a second loop that hits an endpoint which needs the database, because a lot of apps answer a static route happily while their connection pool is still empty.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this doesn't cover
&lt;/h2&gt;

&lt;p&gt;Be honest with yourself about three gaps.&lt;/p&gt;

&lt;p&gt;Database migrations. If the new code needs a column the old code does not know about, both versions run at the same time for a short window. Write migrations that the old code can survive: add columns first, remove them in a later deploy.&lt;/p&gt;

&lt;p&gt;Long-lived connections. WebSockets and server-sent events will be cut when the old container stops. Clients need to reconnect, and they should. If yours don't, that is a bug you have anyway.&lt;/p&gt;

&lt;p&gt;State on disk. If the app writes uploads to a local volume, two containers share that volume, which is fine for reads and a hazard for anything else.&lt;/p&gt;

&lt;p&gt;If this all feels like a lot of ceremony for one app, there are tools that wrap it for you. I compared two of them after migrating a client, in the &lt;a href="https://abrarqasim.com/blog/coolify-vs-dokploy-what-actually-differs-after-migrating-a-client/" rel="noopener noreferrer"&gt;Coolify vs Dokploy write-up&lt;/a&gt;. I still prefer the raw script for boxes where I want to understand every moving part.&lt;/p&gt;

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

&lt;p&gt;Pick one low-traffic service on a VPS you control. Add a real health route, remove its host port, put Caddy in front with &lt;code&gt;dynamic a&lt;/code&gt; and &lt;code&gt;lb_try_duration&lt;/code&gt;, and run the script above while hitting the site with &lt;code&gt;curl&lt;/code&gt; in a loop from another terminal. Count the non-200 responses. If you get zero, you now have a deploy you can run at noon on a Tuesday. If you get some, the loop output tells you exactly which of the pieces is missing. I do this kind of infrastructure work for clients, and you can see more of it on &lt;a href="https://abrarqasim.com" rel="noopener noreferrer"&gt;my portfolio&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://abrarqasim.com/blog/zero-downtime-deployment-docker-compose-caddy-one-vps/" rel="noopener noreferrer"&gt;abrarqasim.com&lt;/a&gt;. I write there about React, PHP, Rust, Go and the AI tooling around them.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>docker</category>
      <category>caddy</category>
      <category>devops</category>
      <category>selfhosting</category>
    </item>
  </channel>
</rss>
