<?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: Escrozon</title>
    <description>The latest articles on DEV Community by Escrozon (@escrozon).</description>
    <link>https://dev.to/escrozon</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%2F4144487%2Fef469f40-0b05-4415-9df7-1770e1e044c7.jpg</url>
      <title>DEV Community: Escrozon</title>
      <link>https://dev.to/escrozon</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/escrozon"/>
    <language>en</language>
    <item>
      <title>Cloudflare cached our 404s: why a new page kept 404ing after a good deploy</title>
      <dc:creator>Escrozon</dc:creator>
      <pubDate>Sat, 26 Sep 2026 18:02:43 +0000</pubDate>
      <link>https://dev.to/escrozon/cloudflare-cached-our-404s-why-a-new-page-kept-404ing-after-a-good-deploy-4502</link>
      <guid>https://dev.to/escrozon/cloudflare-cached-our-404s-why-a-new-page-kept-404ing-after-a-good-deploy-4502</guid>
      <description>&lt;p&gt;We run &lt;a href="https://escrozon.com" rel="noopener noreferrer"&gt;Escrozon&lt;/a&gt;, an escrow marketplace for digital assets, behind Cloudflare. A while ago we shipped a new landing page. The deploy went fine and the server returned the page, but visitors kept getting a 404 for almost an hour.&lt;/p&gt;

&lt;p&gt;Nothing was wrong with the deploy. Cloudflare had cached the 404, and we had caused it ourselves by asking for the page before it existed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happened
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Before the deploy, we checked whether the new URL was already live. It returned 404, which was correct at that moment.&lt;/li&gt;
&lt;li&gt;Our Cloudflare cache rule keeps HTML at the edge for an hour (&lt;code&gt;s-maxage=3600&lt;/code&gt;), and it applied to that 404 as well.&lt;/li&gt;
&lt;li&gt;The deploy finished and the origin served the page with a 200. Cloudflare kept serving its stored 404 until that copy expired.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;It can also look inconsistent. Unless Tiered Cache is on, each Cloudflare data center keeps its own copy, so visitors in one region may see the page while others still get the 404.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to tell a stale cached 404 from a broken deploy
&lt;/h2&gt;

&lt;p&gt;Start with the response headers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sI&lt;/span&gt; https://example.com/new-page | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-iE&lt;/span&gt; &lt;span class="s1"&gt;'^(HTTP|cf-cache-status|age|cache-control)'&lt;/span&gt;
&lt;span class="c"&gt;# HTTP/2 404&lt;/span&gt;
&lt;span class="c"&gt;# cf-cache-status: HIT&lt;/span&gt;
&lt;span class="c"&gt;# age: 2140&lt;/span&gt;
&lt;span class="c"&gt;# cache-control: public, max-age=14400, s-maxage=3600, stale-while-revalidate=86400&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;cf-cache-status: HIT&lt;/code&gt; means Cloudflare answered from its cache without asking your server.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;age&lt;/code&gt; is how many seconds that copy has been stored.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;s-maxage=3600&lt;/code&gt; is how long the edge may keep it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This 404 would stay for another 3600 − 2140 = 1460 seconds, about 24 minutes.&lt;/p&gt;

&lt;p&gt;Next, skip the cached copy by adding a query string. Cloudflare treats it as a different URL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&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="s1"&gt;'%{http_code}\n'&lt;/span&gt; &lt;span class="s2"&gt;"https://example.com/new-page?cb=1"&lt;/span&gt;
&lt;span class="c"&gt;# 200&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A 200 with the query string and a 404 without it means the deploy worked. Only the cached copy is stale.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fixing it right now
&lt;/h2&gt;

&lt;p&gt;You have three options:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Purge that URL.&lt;/strong&gt; In the Cloudflare dashboard, go to Caching → Configuration → Custom Purge and enter the exact URL. "Purge Everything" also works, but it empties the whole cache.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Purge through the API&lt;/strong&gt;, which is handy in a deploy script:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://api.cloudflare.com/client/v4/zones/&lt;/span&gt;&lt;span class="nv"&gt;$ZONE_ID&lt;/span&gt;&lt;span class="s2"&gt;/purge_cache"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
 &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$CF_API_TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
 &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
 &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{"files":["https://example.com/new-page"]}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Wait&lt;/strong&gt; until &lt;code&gt;s-maxage&lt;/code&gt; minus &lt;code&gt;age&lt;/code&gt; runs out.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Stopping it from happening again
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Don't request a public URL before it exists.&lt;/strong&gt; Test on localhost, or directly against your origin server, until the deploy is done. One curl to the public URL is enough to cache the 404.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Give 404s a short cache time.&lt;/strong&gt; Cloudflare Cache Rules let you set an edge TTL per status code. Set 404 (or the whole 4xx range) to no cache, or a few seconds, while pages that work keep their longer TTL.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Purge in your deploy script.&lt;/strong&gt; Call the purge API for the URLs you just shipped, or for everything if the deploy changes shared assets.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Know which setting wins.&lt;/strong&gt; Our app sent &lt;code&gt;s-maxage=300&lt;/code&gt;, but the edge served &lt;code&gt;s-maxage=3600&lt;/code&gt; because a Cloudflare rule overrode the origin. Changing the headers in our Next.js config did nothing, because the rule in the dashboard decides.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Check Browser Cache TTL.&lt;/strong&gt; That Cloudflare setting can override the &lt;code&gt;max-age&lt;/code&gt; your origin sends to browsers. Ours was 4 hours, so returning visitors saw old pages long after a deploy. Setting it to "Respect Existing Headers" put our origin back in charge.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measuring cache performance without fooling yourself
&lt;/h2&gt;

&lt;p&gt;Two traps caught us when we tried to measure how fast the cache was:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A cache-busting query string always forces a MISS.&lt;/strong&gt; It adds a full trip to your origin, so timings measured with it look worse than what real visitors get.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A quick &lt;code&gt;curl -I&lt;/code&gt; warms the cache.&lt;/strong&gt; The next request then looks like a HIT. Measure the status and the timing in the same request:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&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;-D&lt;/span&gt; /tmp/headers.txt &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;'%{http_code} %{time_total}s\n'&lt;/span&gt; https://example.com/page
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; cf-cache-status /tmp/headers.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your origin is far from many of your visitors, also look at Cloudflare's Tiered Cache. Without it, every Cloudflare location fetches from your origin separately, so the first visitor in each region pays the full trip.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;cf-cache-status&lt;/code&gt; and &lt;code&gt;age&lt;/code&gt; tell you whether you're looking at a cached response&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;?cb=1&lt;/code&gt; request shows what your origin serves right now&lt;/li&gt;
&lt;li&gt;Purge the URLs you ship, as part of the deploy&lt;/li&gt;
&lt;li&gt;404s get no cache, or a very short one, in your cache rules&lt;/li&gt;
&lt;li&gt;Nobody requests public URLs for pages that aren't deployed yet&lt;/li&gt;
&lt;li&gt;You know whether your origin headers or your Cloudflare rules set the TTL&lt;/li&gt;
&lt;li&gt;Browser Cache TTL is set to "Respect Existing Headers"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Caching 404s isn't a bug in Cloudflare. It does exactly what the rules say. The fix is making sure the rules say what you meant.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This post was written with AI assistance, based on our own incident notes and the commands we ran in production.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>cloudflare</category>
      <category>webdev</category>
      <category>devops</category>
      <category>nextjs</category>
    </item>
    <item>
      <title>SQLite in production with Prisma and pm2: how we fixed "database is locked"</title>
      <dc:creator>Escrozon</dc:creator>
      <pubDate>Sat, 26 Sep 2026 14:47:56 +0000</pubDate>
      <link>https://dev.to/escrozon/sqlite-in-production-with-prisma-and-pm2-how-we-fixed-database-is-locked-83p</link>
      <guid>https://dev.to/escrozon/sqlite-in-production-with-prisma-and-pm2-how-we-fixed-database-is-locked-83p</guid>
      <description>&lt;p&gt;We run &lt;a href="https://escrozon.com" rel="noopener noreferrer"&gt;Escrozon&lt;/a&gt;, an escrow marketplace for digital assets, on Next.js with Prisma and a single SQLite file. SQLite suits a small production app well: one file, no database server to look after, and fast reads. But for a few weeks we saw failures that looked random. Some requests died with a Prisma timeout, and our nightly backup job kept failing with &lt;code&gt;database is locked&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;They weren't random. Three settings were working against each other. Here's what we found and exactly how we fixed it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The symptoms
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Occasional 500 errors on ordinary pages, more often when several people were active at once&lt;/li&gt;
&lt;li&gt;Prisma errors saying the operation timed out while waiting for the database&lt;/li&gt;
&lt;li&gt;The nightly backup script stopping with &lt;code&gt;Error: database is locked&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The backup script was written to abort instead of saving a broken copy, which was the right call. But it also meant new backups had quietly stopped being made.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cause 1: the default rollback journal
&lt;/h2&gt;

&lt;p&gt;Check which journal mode your database uses:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;sqlite3 db/app.db &lt;span class="s2"&gt;"PRAGMA journal_mode;"&lt;/span&gt;
&lt;span class="c"&gt;# delete&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;delete&lt;/code&gt; is SQLite's default rollback-journal mode. In this mode a write needs an exclusive lock on the database file to commit, and readers have to wait until it finishes. A web app reads on almost every request, so those waits pile up. Once a wait is longer than Prisma's timeout, the request fails.&lt;/p&gt;

&lt;p&gt;Switch to write-ahead logging (WAL):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;sqlite3 db/app.db &lt;span class="s2"&gt;"PRAGMA journal_mode=WAL;"&lt;/span&gt;
&lt;span class="c"&gt;# wal&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In WAL mode, reads and writes don't block each other. There is still only one writer at a time, but readers keep working while it writes. The setting is saved inside the database file, so you run it once. (There's one exception, covered under restores below.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Cause 2: two copies of the app writing to the same file
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;pm2 list&lt;/code&gt; looked normal at a glance. &lt;code&gt;pm2 jlist&lt;/code&gt; told a different story:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pm2 jlist | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.[] | "\(.pm_id) \(.name) \(.pm2_env.status)"'&lt;/span&gt;
&lt;span class="c"&gt;# 5 web online&lt;/span&gt;
&lt;span class="c"&gt;# 7 web online&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two processes with the same name were running the same server and writing to the same SQLite file. Our &lt;code&gt;ecosystem.config.js&lt;/code&gt; declares one app. The second was left over from an old manual &lt;code&gt;pm2 start&lt;/code&gt;, and a &lt;code&gt;pm2 save&lt;/code&gt; had made it come back after every reboot.&lt;/p&gt;

&lt;p&gt;SQLite allows one writer at a time, so a second process only adds lock contention.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pm2 delete 7
pm2 save   &lt;span class="c"&gt;# without this, the duplicate returns on the next reboot&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Cause 3: Prisma's connection pool
&lt;/h2&gt;

&lt;p&gt;Prisma opens a pool of several connections by default. With SQLite, extra connections in the same process just queue behind the same file lock. We limited the pool to one connection and gave queries a clear lock timeout:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"file:./db/app.db?connection_limit=1&amp;amp;socket_timeout=10"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;socket_timeout=10&lt;/code&gt; lets a query wait up to 10 seconds for the lock before it fails, instead of failing almost at once.&lt;/p&gt;

&lt;h2&gt;
  
  
  The result
&lt;/h2&gt;

&lt;p&gt;We fired 24 concurrent writes through Prisma. All 24 succeeded, none hit a lock, and the whole run took about 50 ms. The backup job started working again, and the random 500s stopped.&lt;/p&gt;

&lt;p&gt;Here's a small script to run the same test on your own setup:&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;PrismaClient&lt;/span&gt; &lt;span class="p"&gt;}&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;@prisma/client&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;prisma&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PrismaClient&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// Use a table where test rows are harmless, and delete them afterwards.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;allSettled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
 &lt;span class="nb"&gt;Array&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="na"&gt;length&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
   &lt;span class="nx"&gt;prisma&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;auditLog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`lock-test-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&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;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;failed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;rejected&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;results&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;failed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;failed&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Four gotchas that cost us time
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. pm2's ecosystem file wins over &lt;code&gt;.env&lt;/code&gt;.&lt;/strong&gt; pm2 injects the &lt;code&gt;env&lt;/code&gt; block from &lt;code&gt;ecosystem.config.js&lt;/code&gt;, and Next.js doesn't overwrite a variable that is already set. Changing only &lt;code&gt;.env&lt;/code&gt; did nothing. We had to change the ecosystem file too, plus the &lt;code&gt;.env&lt;/code&gt; inside &lt;code&gt;.next/standalone&lt;/code&gt;, because a standalone build carries its own copy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. &lt;code&gt;pm2 reload web --update-env&lt;/code&gt; doesn't re-read the ecosystem file.&lt;/strong&gt; It reuses the environment pm2 already stored. This picks up the new value:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pm2 reload ecosystem.config.js &lt;span class="nt"&gt;--only&lt;/span&gt; web &lt;span class="nt"&gt;--update-env&lt;/span&gt;
pm2 save
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;3. The &lt;code&gt;sqlite3&lt;/code&gt; CLI reports its own settings, not your app's.&lt;/strong&gt; &lt;code&gt;PRAGMA busy_timeout&lt;/code&gt; in the CLI shows the CLI's connection (0). It tells you nothing about what Prisma uses.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. A typo in the database path creates a new, empty database.&lt;/strong&gt; Running &lt;code&gt;sqlite3&lt;/code&gt; against a file that doesn't exist silently creates it and reports the defaults. We briefly "found" a regression that was really a wrong filename. Check the path with &lt;code&gt;ls&lt;/code&gt; first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Backups and restores in WAL mode
&lt;/h2&gt;

&lt;p&gt;WAL mode adds two files next to the database: &lt;code&gt;app.db-wal&lt;/code&gt; and &lt;code&gt;app.db-shm&lt;/code&gt;. Copying only &lt;code&gt;app.db&lt;/code&gt; while the app is running can give you an incomplete backup. Use SQLite's own backup command, which is safe in WAL mode, and check the copy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;sqlite3 db/app.db &lt;span class="s2"&gt;".backup 'backups/app-&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%F&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;.db'"&lt;/span&gt;
sqlite3 &lt;span class="s2"&gt;"backups/app-&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%F&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;.db"&lt;/span&gt; &lt;span class="s2"&gt;"PRAGMA integrity_check;"&lt;/span&gt;
&lt;span class="c"&gt;# ok&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you ever restore an older snapshot, check the journal mode again. A copy taken before the switch is still in &lt;code&gt;delete&lt;/code&gt; mode.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;PRAGMA journal_mode;&lt;/code&gt; returns &lt;code&gt;wal&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Only one process writes to the database file (check &lt;code&gt;pm2 jlist&lt;/code&gt;, not only &lt;code&gt;pm2 list&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;connection_limit=1&lt;/code&gt; is set in &lt;code&gt;DATABASE_URL&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;The new &lt;code&gt;DATABASE_URL&lt;/code&gt; is in every place pm2 and Next.js read it from&lt;/li&gt;
&lt;li&gt;Backups use &lt;code&gt;.backup&lt;/code&gt; and pass &lt;code&gt;PRAGMA integrity_check&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;The journal mode is checked again after any restore&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Set up this way, SQLite handles far more production traffic than people expect. Almost all of our "database is locked" errors came from our configuration, not from SQLite itself.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This post was written with AI assistance, based on our own incident notes and the commands we ran in production.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>sqlite</category>
      <category>prisma</category>
      <category>node</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
