<?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: jguillaumesio</title>
    <description>The latest articles on DEV Community by jguillaumesio (@jguillaumesio).</description>
    <link>https://dev.to/jguillaumesio</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%2F3987822%2Fe71b8496-6844-440c-be1f-2d92795d48aa.png</url>
      <title>DEV Community: jguillaumesio</title>
      <link>https://dev.to/jguillaumesio</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jguillaumesio"/>
    <language>en</language>
    <item>
      <title>My fine-tuned model scored 100%... The benchmark was lying</title>
      <dc:creator>jguillaumesio</dc:creator>
      <pubDate>Tue, 11 Aug 2026 20:09:08 +0000</pubDate>
      <link>https://dev.to/jguillaumesio/my-fine-tuned-model-scored-100-the-benchmark-was-lying-48fo</link>
      <guid>https://dev.to/jguillaumesio/my-fine-tuned-model-scored-100-the-benchmark-was-lying-48fo</guid>
      <description>&lt;p&gt;I fine-tuned Mistral 7B on my laptop to detect personal data in log lines and support messages. On my first test set it scored 100%. Perfect. Every single line classified correctly.&lt;/p&gt;

&lt;p&gt;I did not publish that number, because the same test set gave few-shot prompting 94%, and a six-point gap over a prompt you can write in five minutes is not a reason to fine-tune anything. The honest conclusion looked like "this was a waste of an afternoon."&lt;/p&gt;

&lt;p&gt;Then I threw my test set away and rebuilt it from real public data. The fine-tune dropped to 95%. Prompting collapsed to 66%.&lt;/p&gt;

&lt;p&gt;Same model, same code, same training recipe. A 6 point gap became a 29 point gap, and the conclusion flipped completely. My benchmark had been choosing my answer for me, and it had chosen wrong.&lt;/p&gt;

&lt;p&gt;This article is the whole run: what LoRA actually does, how to build a dataset that does not lie to you, the exact commands, and the measured results. Everything is reproducible from &lt;a href="https://github.com/jguillaumesio/lora-pii-detection-mlx" rel="noopener noreferrer"&gt;the repository&lt;/a&gt; on any Apple Silicon Mac.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is LoRA real fine-tuning?
&lt;/h2&gt;

&lt;p&gt;Worth settling first, because "LoRA is not really fine-tuning" comes up constantly.&lt;/p&gt;

&lt;p&gt;It is. LoRA trains the model with gradient descent on your data exactly like full fine-tuning. The difference is which weights move. Instead of updating all 7 billion parameters, it freezes them and learns two small low-rank matrices per targeted layer. Their product approximates the weight update that full fine-tuning would have made, and it can be merged back into the base weights afterwards, giving you a genuinely different model.&lt;/p&gt;

&lt;p&gt;In my run, that meant &lt;strong&gt;0.145% of the parameters were trainable&lt;/strong&gt;: 10.5 million out of 7.25 billion. That is the entire reason this fits on a laptop, and why the output is a 42 MB adapter file instead of a new 4 GB model.&lt;/p&gt;

&lt;p&gt;What it is not is prompting or retrieval. The weights actually change. The honest caveat is that full fine-tuning can push a bit further on hard tasks, at ten to a hundred times the memory cost. For teaching a model a format, a taxonomy, or a behaviour, LoRA is what practitioners actually ship, and it is what the fine-tuning APIs from the major providers run under the hood.&lt;/p&gt;

&lt;h2&gt;
  
  
  The task and the hardware
&lt;/h2&gt;

&lt;p&gt;The model gets one line of text, in English or French, and must answer with strict JSON:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"pii"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"types"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"email"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Six types: &lt;code&gt;email&lt;/code&gt;, &lt;code&gt;phone&lt;/code&gt;, &lt;code&gt;name&lt;/code&gt;, &lt;code&gt;iban&lt;/code&gt;, &lt;code&gt;address&lt;/code&gt;, &lt;code&gt;dob&lt;/code&gt;. Empty list when there is nothing.&lt;/p&gt;

&lt;p&gt;This is a real problem, not a toy. Personal data leaks into logs, staging dumps and exports far beyond your users table, which I wrote about in &lt;a href="https://jguillaumesio.com/blog/pii-data-masking/" rel="noopener noreferrer"&gt;PII and data masking&lt;/a&gt;, and knowing where it is is the precondition for &lt;a href="https://jguillaumesio.com/blog/gdpr-data-retention-and-deletion/" rel="noopener noreferrer"&gt;retention and deletion&lt;/a&gt; that actually works.&lt;/p&gt;

&lt;p&gt;Hardware: a MacBook with an Apple M5 and 16 GB of unified memory. Base model &lt;code&gt;mlx-community/Mistral-7B-Instruct-v0.3-4bit&lt;/code&gt;, running on &lt;a href="https://github.com/ml-explore/mlx-lm" rel="noopener noreferrer"&gt;MLX&lt;/a&gt;, Apple's array framework. No cloud, no API keys, total cost 0 EUR.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part nobody writes about: the dataset
&lt;/h2&gt;

&lt;p&gt;Every tutorial shows you &lt;code&gt;mlx_lm.lora --train&lt;/code&gt;. That command is four minutes of work. The dataset is the other three hours, and it is where the result is actually decided.&lt;/p&gt;

&lt;h3&gt;
  
  
  Attempt one, and why it lied
&lt;/h3&gt;

&lt;p&gt;I generated 800 examples from my own templates: log lines with fake emails, support messages with fake IBANs, plus hard negatives full of UUIDs and invoice numbers so the model could not just flag anything that looks like an identifier.&lt;/p&gt;

&lt;p&gt;It gave the fine-tune a perfect score. The reason is obvious in hindsight: &lt;strong&gt;the test set came from the same templates as the training set.&lt;/strong&gt; The model had seen every sentence shape before. I was measuring memorisation of my own imagination.&lt;/p&gt;

&lt;p&gt;If your fine-tune scores 100%, your test set is too easy. That is not a nice problem to have, it is a broken measurement.&lt;/p&gt;

&lt;h3&gt;
  
  
  Attempt two: real data, and a licensing trap
&lt;/h3&gt;

&lt;p&gt;So I went looking for public corpora, and immediately walked into two problems that are worth more than the rest of this article.&lt;/p&gt;

&lt;p&gt;The obvious choice is &lt;code&gt;ai4privacy/pii-masking-200k&lt;/code&gt;, which everybody cites. Its licence is dual: free for individuals, non-profits, and companies with three staff or fewer, paid otherwise. A blog that markets a consulting practice is commercial use. Depending on your situation you may be fine, but "everybody uses it" is not a licence.&lt;/p&gt;

&lt;p&gt;For negatives I grabbed &lt;a href="https://github.com/logpai/loghub" rel="noopener noreferrer"&gt;LogHub&lt;/a&gt;, 32,000 lines of real production logs from 16 systems. Two problems. Its licence covers "research or academic work" only. And, far worse for an article about detecting personal data, several of those systems contain real personal data:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;BGL&lt;/strong&gt; exposes real researcher account names in &lt;code&gt;/home/&lt;/code&gt; paths from national laboratories&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mac&lt;/strong&gt; logs contain a real email address and a real home directory&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Linux&lt;/strong&gt; and &lt;strong&gt;OpenSSH&lt;/strong&gt; carry real usernames from live attack traffic&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;My screening regex caught four email addresses and missed all of it, because a username inside a file path does not look like contact data. I had built a training set that teaches a model that real people's names are &lt;strong&gt;not&lt;/strong&gt; personal data. That is worse than no model at all.&lt;/p&gt;

&lt;p&gt;Both sources were thrown out. The final pipeline uses two Apache-2.0 corpora: &lt;a href="https://huggingface.co/datasets/DataikuNLP/kiji-pii-training-data" rel="noopener noreferrer"&gt;kiji&lt;/a&gt; for positives, which covers all six types including IBAN and date of birth in English and French and ships its own train/test split, and &lt;a href="https://huggingface.co/datasets/witfoo/syslog-to-artifact" rel="noopener noreferrer"&gt;witfoo syslog&lt;/a&gt; for negatives, 155,000 lines of real firewall and system logs.&lt;/p&gt;

&lt;h3&gt;
  
  
  The shortcut that would have faked the score again
&lt;/h3&gt;

&lt;p&gt;Here is the trap that would have quietly ruined everything. Positives are business prose. Negatives are raw syslog. A model can separate those two by &lt;strong&gt;writing style&lt;/strong&gt; and score brilliantly without ever learning what personal data is.&lt;/p&gt;

&lt;p&gt;So every quadrant has to exist:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Positive (has PII)&lt;/th&gt;
&lt;th&gt;Negative (no PII)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Prose&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;kiji sentences&lt;/td&gt;
&lt;td&gt;same sentences, PII replaced by role words&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Log&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;real syslog with real PII injected&lt;/td&gt;
&lt;td&gt;real syslog, untouched&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The prose negatives are built by rewriting each annotated span into a generic role word, so "contact Alice Dupont at &lt;a href="mailto:alice@example.fr"&gt;alice@example.fr&lt;/a&gt;" becomes "contact the customer at the support address". Same sentence structure, same vocabulary, same language, no personal data.&lt;/p&gt;

&lt;p&gt;Then I found the leak inside my own fix. Filler phrases like "the customer" appeared &lt;strong&gt;only&lt;/strong&gt; in negatives, so they became a perfect giveaway. The model could learn my filler vocabulary instead of the task. So 10% of the rows are hybrids: role words everywhere, except one real value left in. Now the filler carries both labels and is useless as a signal.&lt;/p&gt;

&lt;h3&gt;
  
  
  Four defects in the public data
&lt;/h3&gt;

&lt;p&gt;Public datasets are not clean. Screening mine turned up:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Blanket filtering costs three quarters of the corpus.&lt;/strong&gt; Most kiji rows mention a passport or an SSN somewhere, types I do not model. Dropping those rows left 4,180 usable. Replacing just those spans with role words instead left 15,810.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The annotations are incomplete.&lt;/strong&gt; Kiji has a &lt;code&gt;coreferences&lt;/code&gt; field, and it is empty on every single row. So a later mention survives: "Alice Dubois" is annotated, but "Dubois a signé" three sentences down is not, and that real surname lands inside a "no personal data" example. Fix: screen every negative against a 1,519-token name vocabulary harvested from the corpus itself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4.7% of the French rows are encoding-corrupted.&lt;/strong&gt; Accented characters arrive as NUL bytes, so &lt;code&gt;étude&lt;/code&gt; is stored as &lt;code&gt;\x00tude&lt;/code&gt;. Train on those and you are teaching the model mojibake French. 406 rows dropped.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4% of the syslog carries account names in prose&lt;/strong&gt;, like &lt;code&gt;Accepted password for johndoe&lt;/code&gt;. Too few to be worth parsing, so they are dropped wholesale rather than mislabelled as clean.&lt;/p&gt;

&lt;p&gt;Final dataset: 8,000 train, 448 validation, 800 test. 4,572 positives against 4,676 negatives. 2,544 French rows. Zero overlap between splits, verified.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Mistral gotcha that costs you an afternoon
&lt;/h2&gt;

&lt;p&gt;My first smoke test crashed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;jinja2.exceptions.TemplateError: Conversation roles must alternate user/assistant/user/assistant/...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Mistral's chat template &lt;strong&gt;rejects a standalone &lt;code&gt;system&lt;/code&gt; role&lt;/strong&gt;. Unlike Llama or Qwen, it wants strictly alternating user and assistant turns. So the instructions have to ride on the first user turn:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;messages&lt;/span&gt;&lt;span class="sh"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;SYSTEM_PROMPT&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s"&gt;Line: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;assistant&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pii&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;:true,&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;types&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;:[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;email&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;]}&lt;/span&gt;&lt;span class="sh"&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;The dangerous part is not the crash. It is that if you fix this in your evaluation script and forget your dataset builder, training and inference use different prompt shapes, and your adapter looks broken for no visible reason. Build both from the same function.&lt;/p&gt;

&lt;h2&gt;
  
  
  Training
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python &lt;span class="nt"&gt;-m&lt;/span&gt; mlx_lm lora &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--model&lt;/span&gt; mlx-community/Mistral-7B-Instruct-v0.3-4bit &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--train&lt;/span&gt; &lt;span class="nt"&gt;--data&lt;/span&gt; ./data &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--fine-tune-type&lt;/span&gt; lora &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--batch-size&lt;/span&gt; 4 &lt;span class="nt"&gt;--num-layers&lt;/span&gt; 16 &lt;span class="nt"&gt;--iters&lt;/span&gt; 500 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--learning-rate&lt;/span&gt; 1e-5 &lt;span class="nt"&gt;--max-seq-length&lt;/span&gt; 512 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--mask-prompt&lt;/span&gt; &lt;span class="nt"&gt;--grad-checkpoint&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--steps-per-report&lt;/span&gt; 50 &lt;span class="nt"&gt;--steps-per-eval&lt;/span&gt; 250 &lt;span class="nt"&gt;--save-every&lt;/span&gt; 500 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--val-batches&lt;/span&gt; 25 &lt;span class="nt"&gt;--seed&lt;/span&gt; 42 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--adapter-path&lt;/span&gt; ./adapters 2&amp;gt;&amp;amp;1 | &lt;span class="nb"&gt;tee &lt;/span&gt;training.log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why these values:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;--mask-prompt&lt;/code&gt; is the one you must not skip.&lt;/strong&gt; It computes the loss on the answer only, not on the input line. Without it, most of the tokens the model is learning to predict are the log line itself, which is not the task.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;--batch-size 4 --num-layers 16&lt;/code&gt;&lt;/strong&gt; came from measurement, not guesswork. A first run at batch 1 with 8 layers peaked at 4.8 GB on a 16 GB machine, so there was room to double both.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;--learning-rate 1e-5&lt;/code&gt;&lt;/strong&gt; is the consensus for small datasets. 1e-4 oscillates, 1e-6 barely moves.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;--grad-checkpoint&lt;/code&gt;&lt;/strong&gt; trades compute for memory, and &lt;strong&gt;&lt;code&gt;--save-every&lt;/code&gt;&lt;/strong&gt; matters because you want the best checkpoint, not the last one.&lt;/p&gt;

&lt;p&gt;The loss curve is the interesting part:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Iteration&lt;/th&gt;
&lt;th&gt;Validation loss&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;3.537&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;250&lt;/td&gt;
&lt;td&gt;0.508&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;500&lt;/td&gt;
&lt;td&gt;0.491&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Almost everything happens in the first 250 iterations.&lt;/strong&gt; The next 250 bought a 3% improvement, so I stopped there rather than running the 2,000 I had planned. If you take one operational lesson: watch validation loss and stop when it flattens, because "train longer" is mostly a way to spend electricity.&lt;/p&gt;

&lt;p&gt;Final cost: &lt;strong&gt;45 minutes, 6.0 GB peak memory, a 42 MB adapter, 0 EUR.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Results
&lt;/h2&gt;

&lt;p&gt;Every mode uses the same prompts, the same test set and temperature 0. "Few-shot" means six worked examples in the prompt, which is what a sensible engineer tries before reaching for training.&lt;/p&gt;

&lt;h3&gt;
  
  
  400 held-out rows
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Zero-shot&lt;/th&gt;
&lt;th&gt;Few-shot (6)&lt;/th&gt;
&lt;th&gt;LoRA&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Accuracy&lt;/td&gt;
&lt;td&gt;66%&lt;/td&gt;
&lt;td&gt;66%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;95%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Precision&lt;/td&gt;
&lt;td&gt;0.639&lt;/td&gt;
&lt;td&gt;0.610&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.926&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Recall&lt;/td&gt;
&lt;td&gt;0.686&lt;/td&gt;
&lt;td&gt;0.840&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.974&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;F1&lt;/td&gt;
&lt;td&gt;0.662&lt;/td&gt;
&lt;td&gt;0.707&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.950&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;False positives&lt;/td&gt;
&lt;td&gt;75&lt;/td&gt;
&lt;td&gt;104&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;15&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Missed PII&lt;/td&gt;
&lt;td&gt;61&lt;/td&gt;
&lt;td&gt;31&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;5&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Valid JSON&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Seconds per line&lt;/td&gt;
&lt;td&gt;0.83&lt;/td&gt;
&lt;td&gt;1.67&lt;/td&gt;
&lt;td&gt;0.91&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Look at few-shot's precision: adding six examples made it &lt;strong&gt;worse&lt;/strong&gt; than zero-shot, 104 false positives against 75. It found more personal data and cried wolf far more often.&lt;/p&gt;

&lt;p&gt;The per-type breakdown shows why:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Zero-shot&lt;/th&gt;
&lt;th&gt;Few-shot&lt;/th&gt;
&lt;th&gt;LoRA&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;email&lt;/td&gt;
&lt;td&gt;0.745&lt;/td&gt;
&lt;td&gt;0.782&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.000&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;phone&lt;/td&gt;
&lt;td&gt;0.628&lt;/td&gt;
&lt;td&gt;0.575&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.983&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;name&lt;/td&gt;
&lt;td&gt;0.531&lt;/td&gt;
&lt;td&gt;0.663&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.855&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;iban&lt;/td&gt;
&lt;td&gt;0.358&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.194&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.950&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;address&lt;/td&gt;
&lt;td&gt;0.500&lt;/td&gt;
&lt;td&gt;0.597&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.914&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;dob&lt;/td&gt;
&lt;td&gt;0.383&lt;/td&gt;
&lt;td&gt;0.366&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.875&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;IBAN is the story. Six examples cannot teach a model the boundary between an IBAN, an invoice reference and a &lt;code&gt;whsec_&lt;/code&gt; webhook secret across two languages. Few-shot scores 0.194 there, worse than saying nothing. The fine-tune reaches 0.950, because 4,000 examples of that boundary is what it takes.&lt;/p&gt;

&lt;h3&gt;
  
  
  30 hand-written lines the model has never seen
&lt;/h3&gt;

&lt;p&gt;I also wrote 30 lines by hand, in phrasings the corpora never produced, as a final honesty check:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Zero-shot&lt;/th&gt;
&lt;th&gt;Few-shot&lt;/th&gt;
&lt;th&gt;LoRA&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Accuracy&lt;/td&gt;
&lt;td&gt;80%&lt;/td&gt;
&lt;td&gt;90%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;100%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;False positives&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Missed PII&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The fine-tune got all 30. Small sample, so I would not put "100%" on a slide, but it did not fall apart off-distribution, which was the real question.&lt;/p&gt;

&lt;h3&gt;
  
  
  It is also cheaper to run
&lt;/h3&gt;

&lt;p&gt;The fine-tune is &lt;strong&gt;1.8x faster per line than few-shot&lt;/strong&gt;, 0.91 seconds against 1.67. The six examples are gone from the prompt, so every single inference is shorter, forever. Better and cheaper is a rare combination.&lt;/p&gt;

&lt;h2&gt;
  
  
  When you should not do this
&lt;/h2&gt;

&lt;p&gt;Fine-tuning teaches behaviour, format and taxonomy. It does not teach facts.&lt;/p&gt;

&lt;p&gt;If your problem is "the model does not know our internal documentation", fine-tuning is the wrong tool and retrieval is the right one. Facts change; weights do not. You will retrain forever and still get confident wrong answers.&lt;/p&gt;

&lt;p&gt;And prompting deserves a fair trial first. On my synthetic dataset it genuinely tied the fine-tune. It only collapsed when the task got hard enough that six examples could not express the rules. That threshold is the actual decision point, and you cannot find it by reading blog posts, including this one. You find it by measuring both, which costs an afternoon.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lesson that generalises
&lt;/h2&gt;

&lt;p&gt;The technical work here was easy. MLX is excellent, the command is one line, and it ran on a laptop while I did something else.&lt;/p&gt;

&lt;p&gt;The hard part, and the part that decided the outcome, was the data: the licence that quietly excludes commercial use, the "clean" log corpus full of real usernames, the empty coreference field, the NUL-corrupted French, and the style shortcut that would have handed me a beautiful meaningless number.&lt;/p&gt;

&lt;p&gt;I got two completely different answers to the same question, on the same model, on the same day. The only thing that changed was the quality of what I measured against. Before you trust any fine-tuning result, including the ones in this article, ask what the test set is made of.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reproduce it
&lt;/h2&gt;

&lt;p&gt;Everything is public, including the training log and the raw predictions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/jguillaumesio/lora-pii-detection-mlx
&lt;span class="nb"&gt;cd &lt;/span&gt;lora-pii-detection-mlx
python3 &lt;span class="nt"&gt;-m&lt;/span&gt; venv .venv &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; .venv/bin/pip &lt;span class="nb"&gt;install &lt;/span&gt;mlx-lm datasets
.venv/bin/python build_dataset.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The dataset is not committed. It rebuilds from the two Apache-2.0 corpora with that one command, so nothing is redistributed that should not be. The 30 hand-written test lines, the results and the training log are all in the repo.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://jguillaumesio.com/blog/lora-fine-tuning-pii-detection-mac/" rel="noopener noreferrer"&gt;jguillaumesio.com&lt;/a&gt;. I write about payments, AI agents in production, and running SaaS infrastructure without a platform team.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>machinelearning</category>
      <category>llm</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>GDPR, PII and data masking: how to stop leaking personal data everywhere</title>
      <dc:creator>jguillaumesio</dc:creator>
      <pubDate>Sun, 09 Aug 2026 09:34:20 +0000</pubDate>
      <link>https://dev.to/jguillaumesio/gdpr-pii-and-data-masking-how-to-stop-leaking-personal-data-everywhere-f6p</link>
      <guid>https://dev.to/jguillaumesio/gdpr-pii-and-data-masking-how-to-stop-leaking-personal-data-everywhere-f6p</guid>
      <description>&lt;p&gt;The hard part of protecting personal data is not the &lt;code&gt;users&lt;/code&gt; table. Everyone knows that holds PII. The problem is everywhere else it quietly ends up: an email address in an error log, a full production database copied into staging, a customer name in an analytics event, a phone number in a support export, a request body pasted into an LLM prompt.&lt;/p&gt;

&lt;p&gt;PII (personally identifiable information) leaks by default, because copying data is convenient and nobody stops to ask whether the copy still needs to be personal. This is a practical guide to masking it: the techniques, where to apply each, and the one distinction that decides whether you have actually reduced your risk or just moved it.&lt;/p&gt;

&lt;p&gt;This pairs with the &lt;a href="https://jguillaumesio.com/blog/gdpr-data-retention-and-deletion/" rel="noopener noreferrer"&gt;guide on GDPR data retention and deletion&lt;/a&gt;. That one is about how long you keep data and how to delete it. This one is about making the copies you do keep stop being personal data in the first place.&lt;/p&gt;

&lt;p&gt;Usual disclaimer: I am an engineer, not a lawyer. This is implementation guidance, not legal advice.&lt;/p&gt;

&lt;h2&gt;
  
  
  The distinction that actually matters
&lt;/h2&gt;

&lt;p&gt;Three words get used interchangeably and are not the same thing. Getting them straight is the whole game, because they have different legal consequences under GDPR.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Masking / redaction&lt;/strong&gt;: replacing the value with something non-sensitive, usually irreversibly, for display or storage. &lt;code&gt;john@acme.com&lt;/code&gt; becomes &lt;code&gt;j***@acme.com&lt;/code&gt; or &lt;code&gt;[REDACTED]&lt;/code&gt;. The original is gone from that copy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pseudonymization&lt;/strong&gt;: replacing identifiers with a reversible token, where a separate key can map back to the real value. &lt;code&gt;john@acme.com&lt;/code&gt; becomes &lt;code&gt;user_8f3a&lt;/code&gt;, and a lookup table or key can reverse it. GDPR encourages this, but it is critical to understand: &lt;strong&gt;pseudonymized data is still personal data.&lt;/strong&gt; You still hold the mapping, so it is still in scope for retention, erasure, and breach rules.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anonymization&lt;/strong&gt;: transforming data so that no one, including you, can link it back to a person, even by combining it with other data. Truly anonymized data is &lt;strong&gt;no longer personal data&lt;/strong&gt; and falls outside GDPR. This is much harder than it sounds, because re-identification from "anonymous" datasets is a well-documented attack.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The trap is to do pseudonymization, call it anonymization, and assume you are out of scope. You are not. If you kept a way back, it is pseudonymization, and the data is still regulated. Only reach for "anonymized" when you have genuinely thrown away the ability to re-identify.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to mask: static vs dynamic
&lt;/h2&gt;

&lt;p&gt;There are two moments you can mask, and they solve different problems.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Static masking&lt;/strong&gt; transforms data at rest, producing a sanitized copy. This is the one for &lt;strong&gt;non-production environments&lt;/strong&gt;. The classic GDPR failure is cloning the production database into staging so developers have "realistic data", which means every engineer, every CI run, and every less-secured staging box now holds real customer PII. Instead, mask on the way out:&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;-- build a staging dump with names and emails masked&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt;
  &lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'user'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s1"&gt;'@example.com'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;full_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'User '&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;phone&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Better still, generate a &lt;strong&gt;synthetic seed&lt;/strong&gt; that looks real but describes no actual person, so staging never contains production PII at all. Either way, real production data in a non-production environment is the single most common avoidable exposure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dynamic masking&lt;/strong&gt; transforms data at read time, based on who is asking. A support agent sees &lt;code&gt;**** 4242&lt;/code&gt;, a payment service sees the full number. The stored value is intact; the masking happens in the query layer or the API response. Use this when different roles legitimately need different views of the same record, and never rely on the frontend to do it: mask server-side, because anything the client hides, the client can also reveal.&lt;/p&gt;

&lt;h2&gt;
  
  
  Masking techniques, and their pitfalls
&lt;/h2&gt;

&lt;p&gt;Not all masking is equal, and the naive version breaks things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Random masking&lt;/strong&gt; replaces each value with a random one. Safe, but it destroys relationships: the same user gets different masked values in two tables, so your staging data no longer joins.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deterministic masking&lt;/strong&gt; maps each input to the same output every time (usually a keyed hash). &lt;code&gt;john@acme.com&lt;/code&gt; always becomes &lt;code&gt;user_8f3a&lt;/code&gt;, everywhere. This preserves joins and uniqueness, which is what makes masked data actually usable. The catch: deterministic masking is pseudonymization, not anonymization, and it is vulnerable to correlation, so keep the key secret and treat the output as still-personal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Format-preserving masking&lt;/strong&gt; keeps the shape so validators and column types still pass: a masked credit card is still 16 digits, a masked email still has an &lt;code&gt;@&lt;/code&gt;. Necessary when downstream code checks format.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Do not log PII in the first place
&lt;/h2&gt;

&lt;p&gt;Logs deserve their own section because they are the leak nobody notices until a breach. An email in a stack trace, an IP in an access log, a full request body captured "temporarily" for debugging: all of it is personal data, sitting in a system designed to be append-only and widely readable.&lt;/p&gt;

&lt;p&gt;You will not run per-user deletes across a log firehose, so the control has to be at write time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Structured logging with an allowlist.&lt;/strong&gt; Log explicit fields, never whole objects. &lt;code&gt;logger.info({ userId, action })&lt;/code&gt;, not &lt;code&gt;logger.info(user)&lt;/code&gt;, which dumps the email, name, and everything else the moment someone logs the object.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A redaction layer.&lt;/strong&gt; Most logging libraries support redaction paths that strip known-sensitive keys (&lt;code&gt;password&lt;/code&gt;, &lt;code&gt;email&lt;/code&gt;, &lt;code&gt;authorization&lt;/code&gt;, &lt;code&gt;token&lt;/code&gt;) before anything is written. Configure it once, centrally.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Detection for free text.&lt;/strong&gt; Structured redaction misses PII embedded in free-form strings (an email typed into a message body). For that, a detection library like Microsoft Presidio can find and redact common PII patterns (emails, phone numbers, names) before logging or before data leaves your system.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The cheapest control by far is not writing the PII down. Redaction after the fact is always more expensive and never complete.&lt;/p&gt;

&lt;h2&gt;
  
  
  A practical approach
&lt;/h2&gt;

&lt;p&gt;You do not need a data-masking platform to start. In order of impact:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Stop cloning prod into non-prod.&lt;/strong&gt; Mask the dump, or seed synthetic data. This kills the biggest exposure immediately.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Redact PII at the logging layer.&lt;/strong&gt; Allowlist fields, strip sensitive keys, add free-text detection where you log user content.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use deterministic masking&lt;/strong&gt; when masked data must stay joinable, and remember it is still pseudonymized (keep the key safe).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mask dynamically&lt;/strong&gt; for role-based views, always server-side.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Be honest about anonymized vs pseudonymized.&lt;/strong&gt; If you kept a way back, it is still personal data, with all the retention and erasure duties that implies.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of this is exotic. It is mostly deciding, per copy of the data, whether that copy needs to identify a real person, and if not, removing the ability for it to do so. The systems that get breached are rarely breached through the &lt;code&gt;users&lt;/code&gt; table. They are breached through the fourth staging box and the two-year log archive that everyone forgot were full of real people.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://jguillaumesio.com/blog/pii-data-masking/" rel="noopener noreferrer"&gt;jguillaumesio.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>gdpr</category>
      <category>backend</category>
      <category>database</category>
    </item>
    <item>
      <title>GDPR data retention and deletion: a practical guide for developers</title>
      <dc:creator>jguillaumesio</dc:creator>
      <pubDate>Sun, 09 Aug 2026 09:32:40 +0000</pubDate>
      <link>https://dev.to/jguillaumesio/gdpr-data-retention-and-deletion-a-practical-guide-for-developers-58jf</link>
      <guid>https://dev.to/jguillaumesio/gdpr-data-retention-and-deletion-a-practical-guide-for-developers-58jf</guid>
      <description>&lt;p&gt;Most GDPR work that lands on engineers is not about cookie banners or privacy policies. It comes down to two duties that are easy to state and hard to implement: do not keep personal data longer than you need it, and delete it properly when you are supposed to.&lt;/p&gt;

&lt;p&gt;Both are retention and deletion problems, and both are where real systems quietly fall out of compliance. This is a practical guide to getting them right, including the part almost every plan forgets: your backups.&lt;/p&gt;

&lt;p&gt;One disclaimer up front. I am an engineer, not a lawyer, and this is implementation guidance, not legal advice. The exact retention periods and legal bases for your product are a question for your DPO or counsel. What follows is how to build the machinery once those decisions are made.&lt;/p&gt;

&lt;h2&gt;
  
  
  Duty one: storage limitation (stop keeping data forever)
&lt;/h2&gt;

&lt;p&gt;GDPR's storage limitation principle says personal data should be kept only as long as it serves the purpose you collected it for. In engineering terms: every category of personal data needs a defined lifetime and something that enforces it.&lt;/p&gt;

&lt;p&gt;The trap is that systems default to keeping everything forever. Disk is cheap, deletes feel risky, and "we might need it later" wins every argument. So the first, unglamorous step is a &lt;strong&gt;retention schedule&lt;/strong&gt;: a table of what personal data you hold, why, and for how long.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Data&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;th&gt;Retention&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Account profile&lt;/td&gt;
&lt;td&gt;Provide the service&lt;/td&gt;
&lt;td&gt;Life of account + 30 days&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Order history&lt;/td&gt;
&lt;td&gt;Legal and tax obligation&lt;/td&gt;
&lt;td&gt;6 to 10 years (jurisdiction dependent)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Support messages&lt;/td&gt;
&lt;td&gt;Customer service&lt;/td&gt;
&lt;td&gt;2 years&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Server and access logs&lt;/td&gt;
&lt;td&gt;Security and debugging&lt;/td&gt;
&lt;td&gt;30 to 90 days&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Analytics events&lt;/td&gt;
&lt;td&gt;Product analytics&lt;/td&gt;
&lt;td&gt;14 months&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The retention column is a legal decision. The point for engineers is that once it exists, it becomes a spec you can implement and test against.&lt;/p&gt;

&lt;h3&gt;
  
  
  Enforcing it: automated purging
&lt;/h3&gt;

&lt;p&gt;A retention policy that relies on someone remembering to delete things is not a policy. Automate it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A scheduled job (nightly or weekly) that hard-deletes records past their retention window.&lt;/li&gt;
&lt;li&gt;For time-series data (logs, events, analytics), use the storage engine's own TTL or partition dropping rather than row-by-row deletes. Drop the old partition, expire the index, set the bucket lifecycle rule. It is faster and it cannot silently miss rows.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- example: purge support messages older than 2 years&lt;/span&gt;
&lt;span class="k"&gt;DELETE&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;support_messages&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="s1"&gt;'2 years'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Soft delete is not deletion
&lt;/h3&gt;

&lt;p&gt;A common mistake: marking a row &lt;code&gt;deleted_at = now()&lt;/code&gt; and calling it done. A soft delete hides data from the application, but the personal data is still there, fully readable in the database. That is fine as an intermediate state (a grace period, an undo window), but it does not satisfy retention or erasure on its own. Something has to hard-delete it eventually.&lt;/p&gt;

&lt;h2&gt;
  
  
  Duty two: the right to erasure
&lt;/h2&gt;

&lt;p&gt;When a user exercises their right to erasure (the "right to be forgotten"), you generally have 30 days to actually remove their personal data. The difficulty is almost never the main &lt;code&gt;users&lt;/code&gt; row. It is that personal data has spread.&lt;/p&gt;

&lt;p&gt;By the time a product is real, a single user's data lives in many places:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the primary database (and its read replicas)&lt;/li&gt;
&lt;li&gt;a search index (Elasticsearch, Algolia, OpenSearch)&lt;/li&gt;
&lt;li&gt;caches (Redis, a CDN)&lt;/li&gt;
&lt;li&gt;object storage (avatars, uploads, exports)&lt;/li&gt;
&lt;li&gt;the analytics pipeline and warehouse&lt;/li&gt;
&lt;li&gt;third-party processors (email, payments, support, error tracking)&lt;/li&gt;
&lt;li&gt;log storage&lt;/li&gt;
&lt;li&gt;backups&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Erasure means cascading the delete across all of these, not just the row. Two things make this survivable:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A data map.&lt;/strong&gt; You cannot delete what you have not written down. Maintain a list of every system that stores personal data and how to delete from each. This is the same map your retention schedule needs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Delegate to your processors.&lt;/strong&gt; For third parties, you usually do not delete their copy yourself, you call their deletion API or rely on their contractual retention. Stripe, your email provider, your error tracker each have a deletion mechanism. Erasure includes triggering theirs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A clean implementation is an erasure workflow: one job that fans out deletion to every system on the map, records what it did, and is idempotent so it can be re-run if one downstream call fails.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part everyone forgets: backups
&lt;/h2&gt;

&lt;p&gt;Here is the question that stops most erasure plans cold. You have deleted the user from production, from the search index, from object storage, from every processor. But last night's database backup still contains them. So does the one from the night before, and every one going back weeks. Have you actually complied?&lt;/p&gt;

&lt;p&gt;You cannot realistically open each backup, surgically remove one user, and repackage it. That would defeat the purpose of backups and risk corrupting them. Regulators know this, and the accepted engineering answers are these:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Bounded backup retention.&lt;/strong&gt; If your backups rotate on a defined schedule (say 30 days) and then are destroyed, the deleted user's data ages out of the backup set within that window. Document this. "Backups are retained for 30 days, after which erased data is permanently gone" is a defensible, common position. The key is that the window is finite and enforced, not "we keep backups forever."&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Crypto-shredding.&lt;/strong&gt; Encrypt each user's personal data with a per-user key. To erase the user, you delete their key. The ciphertext may still sit in old backups, but without the key it is unrecoverable, which for practical and regulatory purposes is deletion. This is the strongest approach and the one to reach for when your backup retention is long or legally required to be.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;A restore-and-re-erase procedure.&lt;/strong&gt; Accept that backups still contain the data, and commit in writing that if you ever restore a backup, you immediately re-run the erasure job for anyone who requested deletion in the meantime. Keep the list of erasure requests so this is possible. Weaker than the first two, but honest and workable.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The wrong answer is to have no answer. "We delete from the database" while unbounded backups quietly retain everyone forever is the gap auditors and breach investigations find first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do not forget your logs
&lt;/h2&gt;

&lt;p&gt;Logs are the most overlooked store of personal data. An email address in an error message, an IP in an access log, a full request body captured during debugging: all of it is personal data, subject to the same retention and erasure duties, and scattered across a log system that is designed to be append-only.&lt;/p&gt;

&lt;p&gt;You will not run per-user deletes across a log firehose, so handle logs at the source:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Do not log PII in the first place.&lt;/strong&gt; Redact emails, tokens, and request bodies before they are written. This is by far the cheapest control.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set a short retention.&lt;/strong&gt; 30 to 90 days covers almost all security and debugging needs. Configure it and let old logs expire automatically.&lt;/li&gt;
&lt;li&gt;Treat "we can search two years of logs" as a liability, not a feature.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  A short checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A written retention schedule: every category of personal data, its purpose, its lifetime.&lt;/li&gt;
&lt;li&gt;Automated purging that enforces the schedule (TTLs, partition drops, scheduled jobs).&lt;/li&gt;
&lt;li&gt;A data map: every system that stores personal data and how to delete from each.&lt;/li&gt;
&lt;li&gt;An idempotent erasure workflow that cascades across all of them, including processors.&lt;/li&gt;
&lt;li&gt;A documented, defensible answer for backups: bounded retention, crypto-shredding, or restore-and-re-erase.&lt;/li&gt;
&lt;li&gt;No PII in logs, and short log retention.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this is exotic engineering. It is mostly bookkeeping (knowing where the data is) plus a few scheduled jobs. The reason it so often fails is not technical difficulty, it is that nobody owns the map. Write the map down, automate the deletes, and decide your backup story on purpose rather than by accident. That is the difference between a system that is compliant and one that merely looks compliant until someone asks.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://jguillaumesio.com/blog/gdpr-data-retention-and-deletion/" rel="noopener noreferrer"&gt;jguillaumesio.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>gdpr</category>
      <category>backend</category>
      <category>security</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Docker never restarted my database: the price of restart: no</title>
      <dc:creator>jguillaumesio</dc:creator>
      <pubDate>Sun, 09 Aug 2026 09:30:56 +0000</pubDate>
      <link>https://dev.to/jguillaumesio/docker-never-restarted-my-database-the-price-of-restart-no-4hi4</link>
      <guid>https://dev.to/jguillaumesio/docker-never-restarted-my-database-the-price-of-restart-no-4hi4</guid>
      <description>&lt;p&gt;Something died in the middle of the night. I found out in the morning, from a user, not from a machine.&lt;/p&gt;

&lt;p&gt;The container had exited. It stayed exited. Docker had watched it die and done nothing, precisely because I had told it to do nothing. The line responsible was three characters long, repeated on every service in my compose file.&lt;/p&gt;

&lt;p&gt;This is part 5 of a series on hardening a solo-built SaaS in production. The setup and the full list of weak spots are in &lt;a href="https://jguillaumesio.com/blog/docker-compose-production-single-vps/" rel="noopener noreferrer"&gt;the pillar article&lt;/a&gt;. This one is about the difference between a container that crashes and a system that recovers.&lt;/p&gt;

&lt;h2&gt;
  
  
  The line, on all seven services
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;no&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every service had it: nginx, the API, Postgres, Redis, MinIO, the dashboard build, the workspace helper. Seven out of seven.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;restart: no&lt;/code&gt; is Docker's default, and it means exactly what it says. If the process exits, for any reason, the container stays dead. There is no supervisor, no retry, no backoff. It also means something worse that I had not thought through: &lt;strong&gt;if the VPS reboots, nothing comes back up.&lt;/strong&gt; The machine boots, the Docker daemon starts, and every container sits there stopped, waiting for me to SSH in and run &lt;code&gt;make up&lt;/code&gt;. A host reboot after a kernel update would have taken the whole product offline until I noticed by hand.&lt;/p&gt;

&lt;p&gt;For a laptop, &lt;code&gt;restart: no&lt;/code&gt; is the sane default: you do not want yesterday's experiment relaunching itself when you reboot. On a production server, it is a decision to have no automatic recovery at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that surprises people: I had healthchecks
&lt;/h2&gt;

&lt;p&gt;Here is what makes this worth writing about. It was not that I had no monitoring at the container level. Two services had real, well-written healthchecks:&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;db&lt;/span&gt;&lt;span class="pi"&gt;:&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="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;CMD-SHELL'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;pg_isready&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-U&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;${DB_USER}&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-d&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;${DB_NAME}'&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;10s&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;5s&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;5&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;api&lt;/span&gt;&lt;span class="pi"&gt;:&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="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;CMD'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;bun'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;-e'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fetch('http://localhost:8000/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;30s&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;10s&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;40s&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the API even waits for the database properly, which is the right pattern:&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;api&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_healthy&lt;/span&gt;    &lt;span class="c1"&gt;# not just "started"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So why did none of that help? Because of a fact that catches almost everyone coming from Kubernetes:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;In plain Docker Compose, a failing healthcheck does not restart anything.&lt;/strong&gt; It flips the container's status to &lt;code&gt;unhealthy&lt;/code&gt; and stops there. There is no liveness probe semantics, no automatic replacement. Unlike a Kubernetes liveness probe or Docker Swarm, Compose has no built-in actor that reacts to &lt;code&gt;unhealthy&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So a healthcheck without a restart policy produces a container that is beautifully, accurately labelled as broken, that nobody restarts and nobody is watching.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two mechanisms, two different failure modes
&lt;/h2&gt;

&lt;p&gt;Once you see it this way, the design becomes obvious. They are not alternatives, they cover different failures:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Failure&lt;/th&gt;
&lt;th&gt;What catches it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;The process crashes or exits&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Restart policy&lt;/strong&gt; (&lt;code&gt;restart:&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The process is alive but wedged, deadlocked, or not serving&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Healthcheck&lt;/strong&gt; plus something that acts on it&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I had the second half of the second row missing, and the entire first row missing. Which is how a crash at 3am became a morning outage.&lt;/p&gt;

&lt;h2&gt;
  
  
  Choosing a restart policy
&lt;/h2&gt;

&lt;p&gt;There are four values, and only one is usually right for a production service:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Policy&lt;/th&gt;
&lt;th&gt;Restarts on crash&lt;/th&gt;
&lt;th&gt;Survives host reboot&lt;/th&gt;
&lt;th&gt;Respects a manual stop&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;no&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;on-failure[:N]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;only on non-zero exit&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;always&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;no&lt;/strong&gt;, it comes back after a deliberate stop&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;unless-stopped&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;unless-stopped&lt;/code&gt; is the one you want for long-running services. It restarts the container on crash, brings it back after the host reboots, and still lets you deliberately &lt;code&gt;docker compose stop&lt;/code&gt; a service without Docker second-guessing you the next time the daemon starts. &lt;code&gt;always&lt;/code&gt; looks similar but will resurrect a container you intentionally stopped, which is maddening during an incident.&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;db&lt;/span&gt;&lt;span class="pi"&gt;:&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;api&lt;/span&gt;&lt;span class="pi"&gt;:&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;nginx&lt;/span&gt;&lt;span class="pi"&gt;:&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note that this applies to long-running services only. One-shot containers (a build step, a migration runner, the workspace helper that just runs an install) should stay &lt;code&gt;no&lt;/code&gt;, because restarting a job that already completed is not recovery, it is a loop.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making "unhealthy" actually do something
&lt;/h2&gt;

&lt;p&gt;Restart policies handle crashes. They do nothing for the nastier case: the process is still running, so Docker is happy, but it stopped serving. That is what healthchecks detect, and in Compose you need to add the actor yourself. Two honest options:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A small &lt;strong&gt;autoheal sidecar&lt;/strong&gt;: a container with access to the Docker socket that watches for &lt;code&gt;unhealthy&lt;/code&gt; status and restarts those containers. It is a handful of lines in the compose file and it closes the gap without introducing an orchestrator.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;External monitoring that alerts you&lt;/strong&gt;, so a human decides. Slower, but it also catches the failures a restart cannot fix (a full disk, a dead dependency, an expired certificate).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The second one is not optional even if you add the first, because "restart it" is not always the right answer, and a service that silently restart-loops all night is its own kind of outage. Getting that visibility is the next article in this series.&lt;/p&gt;

&lt;p&gt;While you are in there, close the smaller gaps too. In my file, only the database and the API had healthchecks: Redis, MinIO and nginx had none. And nginx used the short form of &lt;code&gt;depends_on&lt;/code&gt;, which only waits for the other containers to have &lt;em&gt;started&lt;/em&gt;, not to be &lt;em&gt;healthy&lt;/em&gt;:&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="c1"&gt;# before: nginx starts as soon as api exists, ready or not&lt;/span&gt;
&lt;span class="na"&gt;nginx&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;storage&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;api&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="c1"&gt;# after: nginx waits until they can actually serve&lt;/span&gt;
&lt;span class="na"&gt;nginx&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;api&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_healthy&lt;/span&gt;
    &lt;span class="na"&gt;storage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_healthy&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What this does not fix
&lt;/h2&gt;

&lt;p&gt;Restart policies keep you up through crashes and reboots. They do not give you zero-downtime deploys. My deploys still run &lt;code&gt;docker compose up -d --force-recreate&lt;/code&gt;, which stops the container and starts a new one, so every deploy is a few seconds where requests hit nothing. That is a different problem with a different fix (build the image elsewhere, then swap), and it belongs with the deploy pipeline, alongside &lt;a href="https://jguillaumesio.com/blog/docker-build-filled-the-disk/" rel="noopener noreferrer"&gt;the day my build filled the disk&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Being honest about the boundary matters: &lt;code&gt;unless-stopped&lt;/code&gt; turns "down until I wake up" into "down for a few seconds". That is a huge win, and it is not the same as high availability. On one VPS, there is still exactly one of everything.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;restart: no&lt;/code&gt; is the right default on a laptop and a decision to have no recovery on a server. If your production compose file has it, a crash at 3am is an outage until you notice, and a host reboot is a full outage until you SSH in.&lt;/p&gt;

&lt;p&gt;Set &lt;code&gt;unless-stopped&lt;/code&gt; on every long-running service. Give every service a healthcheck, not just the database. Use &lt;code&gt;condition: service_healthy&lt;/code&gt; in &lt;code&gt;depends_on&lt;/code&gt; so things start in an order that actually works. And remember the part that trips people up: in Compose, a healthcheck only labels the problem. Something else has to act on it, whether that is an autoheal sidecar or an alert that reaches you.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://jguillaumesio.com/blog/docker-never-restarted-my-database/" rel="noopener noreferrer"&gt;jguillaumesio.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>docker</category>
      <category>devops</category>
      <category>sre</category>
      <category>linux</category>
    </item>
    <item>
      <title>Shipping a SaaS on one VPS with Docker Compose: an honest audit</title>
      <dc:creator>jguillaumesio</dc:creator>
      <pubDate>Sun, 09 Aug 2026 09:29:37 +0000</pubDate>
      <link>https://dev.to/jguillaumesio/shipping-a-saas-on-one-vps-with-docker-compose-an-honest-audit-5g81</link>
      <guid>https://dev.to/jguillaumesio/shipping-a-saas-on-one-vps-with-docker-compose-an-honest-audit-5g81</guid>
      <description>&lt;p&gt;The entire product runs on a single VPS. One machine, one &lt;code&gt;docker-compose.yml&lt;/code&gt;, a containerised nginx in front, and a &lt;code&gt;make deploy&lt;/code&gt; I run over SSH. It serves real, paying customers, and it has never been down for more than a few minutes. It is also, by any serious production standard, held together with tape.&lt;/p&gt;

&lt;p&gt;I built this SaaS end to end for a small agency: a marketplace that connects brands with creators. Solo. The infrastructure reflects that. Every choice optimised for "one person ships this next week," not "this survives a datacentre fire." This article is the honest audit: what the setup is, why it was the right call at launch, and a full map of everything that can take it down.&lt;/p&gt;

&lt;p&gt;It is also the start of a series. Each single point of failure below becomes its own article, built around the real incident that forced me to fix it. This is part 0: the state of the system before any of that hardening.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why one VPS and Docker Compose was the right call
&lt;/h2&gt;

&lt;p&gt;I want to defend this setup before I tear it apart, because the instinct to reach for Kubernetes on day one is how solo projects die. You spend your runway operating a cluster instead of shipping the features that decide whether the product lives.&lt;/p&gt;

&lt;p&gt;One VPS and one compose file buys you the thing that actually matters early: a single mental model. Everything lives in one file. One machine you can SSH into and reason about end to end. And Docker Compose is not a toy: it gives you named networks, service dependencies, healthchecks, and log rotation out of the box. For a product finding its first customers, that is genuinely enough.&lt;/p&gt;

&lt;p&gt;The mistake is not starting here. The mistake is staying here without knowing exactly where the sharp edges are. So here they are.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual architecture
&lt;/h2&gt;

&lt;p&gt;Six services, one network, nginx as the only public entrypoint:&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="c1"&gt;# docker-compose.yml (trimmed and anonymised)&lt;/span&gt;
&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;nginx&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;      &lt;span class="c1"&gt;# reverse proxy + TLS, the only thing exposed to the internet&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;nginx:1.24&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="s1"&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="s1"&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;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;no&lt;/span&gt;
  &lt;span class="na"&gt;api&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;        &lt;span class="c1"&gt;# Bun + Express, Postgres + Redis/BullMQ&lt;/span&gt;
    &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;context&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;.&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;dockerfile&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;apps/api/Dockerfile&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;8000:8000'&lt;/span&gt;&lt;span class="pi"&gt;]&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;no&lt;/span&gt;
  &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;         &lt;span class="c1"&gt;# Postgres 15, data in a local bind mount&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;postgres:15&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="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;127.0.0.1:5432:5432'&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="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;./postgres/data:/var/lib/postgresql/data'&lt;/span&gt;&lt;span class="pi"&gt;]&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;no&lt;/span&gt;
  &lt;span class="na"&gt;redis&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;redis:7&lt;/span&gt;
  &lt;span class="na"&gt;storage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;    &lt;span class="c1"&gt;# MinIO, S3-compatible object storage&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="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;9000:9000'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;9001:9001'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;   &lt;span class="c1"&gt;# 9001 is the admin console&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;no&lt;/span&gt;
  &lt;span class="na"&gt;dashboard&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;  &lt;span class="c1"&gt;# React + Vite, built to static files served by nginx&lt;/span&gt;
    &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;context&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;.&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;dockerfile&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;apps/dashboard/Dockerfile&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The dashboard is a React single-page app served as static files. The API is a Bun and Express monorepo talking to Postgres, Redis, and a BullMQ queue. MinIO holds uploads. Persistent data (Postgres, MinIO) sits in bind mounts on the host filesystem.&lt;/p&gt;

&lt;p&gt;Read that compose file with a critical eye and the problems are already visible. Two are worth flagging now because they are the good habit and the bad habit sitting side by side. Postgres binds to &lt;code&gt;127.0.0.1:5432&lt;/code&gt;, so it is not reachable from the internet: that is the right reflex. The MinIO admin console on &lt;code&gt;9001&lt;/code&gt; is published to every interface: that one is a hole, and it even carries a &lt;code&gt;TODO&lt;/code&gt; in the real file. More on that in the security article.&lt;/p&gt;

&lt;h2&gt;
  
  
  How a deploy works today
&lt;/h2&gt;

&lt;p&gt;There is no pipeline. A deploy is me, on the server, typing:&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;# on the production server, over SSH&lt;/span&gt;
git pull
make deploy &lt;span class="nv"&gt;SERVICE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;api
&lt;span class="c"&gt;# which runs, roughly:&lt;/span&gt;
&lt;span class="c"&gt;#   docker compose build api           # builds the image ON the prod box&lt;/span&gt;
&lt;span class="c"&gt;#   docker compose up -d --force-recreate api&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things are wrong with this, and both get their own article. The image is built on the same machine that is serving live traffic, so the build competes with customers for CPU, RAM, and disk. And &lt;code&gt;--force-recreate&lt;/code&gt; means the container stops and restarts, so every deploy is a few seconds of downtime with nothing catching the requests in between.&lt;/p&gt;

&lt;p&gt;It works. It is also exactly the kind of "works until it doesn't" that this series is about.&lt;/p&gt;

&lt;h2&gt;
  
  
  The map: every single point of failure
&lt;/h2&gt;

&lt;p&gt;None of these are hypothetical. Each one bit me, and each one is the hook for its own article.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;The weak spot&lt;/th&gt;
&lt;th&gt;Why it hurts&lt;/th&gt;
&lt;th&gt;Covered in&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Build runs on the prod box&lt;/td&gt;
&lt;td&gt;The build filled the disk and took production down&lt;/td&gt;
&lt;td&gt;Article 1: the disk that killed prod&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Docker and the host firewall disagree&lt;/td&gt;
&lt;td&gt;Docker writes its own iptables rules, and a port I thought was blocked was open to the internet&lt;/td&gt;
&lt;td&gt;Article 2: Docker bypasses your firewall&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tests exist but never run&lt;/td&gt;
&lt;td&gt;Real regressions shipped while the unit tests sat unused in the repo&lt;/td&gt;
&lt;td&gt;Article 3: tests nobody ran&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Nothing protects &lt;code&gt;main&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;A Friday &lt;code&gt;git push&lt;/code&gt; deployed straight to prod, untested, with downtime&lt;/td&gt;
&lt;td&gt;Article 4: ban commits on main&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;restart: no&lt;/code&gt; on every service&lt;/td&gt;
&lt;td&gt;A crash at night was never restarted, including the database&lt;/td&gt;
&lt;td&gt;Article 5: when Docker watches prod die&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No status view, logs only over SSH&lt;/td&gt;
&lt;td&gt;During an incident I was grepping JSON in a terminal to find out what broke&lt;/td&gt;
&lt;td&gt;Article 6: a status dashboard and centralised logs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Errors die in a log file&lt;/td&gt;
&lt;td&gt;Users reported 500s by email before I ever saw them&lt;/td&gt;
&lt;td&gt;Article 7: real error tracking&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Constant attacks, a certificate that silently expired&lt;/td&gt;
&lt;td&gt;Scans and brute-force all day, plus a renewal cron that ran a broken path&lt;/td&gt;
&lt;td&gt;Article 8: CrowdSec, and Cloudflare as the next layer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bind mounts, no backup&lt;/td&gt;
&lt;td&gt;A &lt;code&gt;pgdata&lt;/code&gt; directory got wiped, with no volume and no backup behind it&lt;/td&gt;
&lt;td&gt;Article 9: backups that actually restore&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One machine, and a client request&lt;/td&gt;
&lt;td&gt;The client wanted preprod wired to production data&lt;/td&gt;
&lt;td&gt;Article 10: when the client wants prod data in preprod&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Where this series goes
&lt;/h2&gt;

&lt;p&gt;The thread through all ten is the same: harden a solo-built SaaS without reaching for Kubernetes and without blowing a small agency's budget. Everything stays on a VPS you can afford. The goal is not a perfect platform. It is a production you can sleep through.&lt;/p&gt;

&lt;p&gt;Next up is the one with the best story and the sharpest lesson: the day a &lt;code&gt;docker compose build&lt;/code&gt; filled the disk and took the whole thing down.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://jguillaumesio.com/blog/docker-compose-production-single-vps/" rel="noopener noreferrer"&gt;jguillaumesio.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>docker</category>
      <category>selfhosted</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Docker bypassed my firewall: the database port was open to the internet</title>
      <dc:creator>jguillaumesio</dc:creator>
      <pubDate>Sun, 09 Aug 2026 09:27:40 +0000</pubDate>
      <link>https://dev.to/jguillaumesio/docker-bypassed-my-firewall-the-database-port-was-open-to-the-internet-1345</link>
      <guid>https://dev.to/jguillaumesio/docker-bypassed-my-firewall-the-database-port-was-open-to-the-internet-1345</guid>
      <description>&lt;p&gt;I had a firewall. I had explicitly blocked the Postgres port on the host. And Postgres was still reachable from the internet.&lt;/p&gt;

&lt;p&gt;I found out the way you never want to: the database was taking connection attempts from IP addresses that had no business talking to it. My firewall rules said port 5432 was closed. A scan from a machine outside the server said it was wide open. Both were telling the truth, and the gap between them is one of the most common production traps in the Docker world.&lt;/p&gt;

&lt;p&gt;This is part 2 of a series on hardening a solo-built SaaS in production. The setup and the full list of weak spots are in &lt;a href="https://jguillaumesio.com/blog/docker-compose-production-single-vps/" rel="noopener noreferrer"&gt;the pillar article&lt;/a&gt;. This one is about the day I learned that Docker does not care about the firewall I thought I had.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the firewall did nothing
&lt;/h2&gt;

&lt;p&gt;Here is the mental model most people have, and why it is wrong. You picture traffic hitting the host, going through your &lt;code&gt;INPUT&lt;/code&gt; chain, and getting dropped by your rule. Tools like &lt;code&gt;ufw&lt;/code&gt; reinforce this: you type &lt;code&gt;ufw deny 5432&lt;/code&gt;, it says the rule is active, and you move on.&lt;/p&gt;

&lt;p&gt;But a published Docker port does not travel through &lt;code&gt;INPUT&lt;/code&gt;. When you publish a port, Docker rewrites the firewall for you. It adds a &lt;code&gt;DNAT&lt;/code&gt; rule in the &lt;code&gt;nat&lt;/code&gt; table that redirects incoming traffic to the container, and it accepts that traffic through the &lt;code&gt;FORWARD&lt;/code&gt; chain, because from the kernel's point of view the packet is being routed to another network (the container's), not delivered to the host itself.&lt;/p&gt;

&lt;p&gt;Your &lt;code&gt;ufw deny&lt;/code&gt; and your handwritten &lt;code&gt;INPUT ... DROP&lt;/code&gt; rules govern the &lt;code&gt;INPUT&lt;/code&gt; chain. Docker's published ports are handled in &lt;code&gt;FORWARD&lt;/code&gt;. The two never meet. So the firewall was doing exactly what I told it: dropping traffic to the host on 5432. Docker was quietly forwarding traffic to the container on 5432, through a completely different path, and it wins because it runs first.&lt;/p&gt;

&lt;p&gt;The convenience that makes Docker pleasant (publish a port and it just works) is the exact mechanism that punched a hole through my firewall without a word.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one binding that was already right
&lt;/h2&gt;

&lt;p&gt;There is a good habit in the compose file, and it is worth calling out because it is the cheapest fix of all. The database publishes its port like this:&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;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;127.0.0.1:5432:5432'&lt;/span&gt;   &lt;span class="c1"&gt;# loopback only, not the whole internet&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;127.0.0.1:&lt;/code&gt; prefix matters enormously. Without it, &lt;code&gt;- '5432:5432'&lt;/code&gt; tells Docker to publish on &lt;code&gt;0.0.0.0&lt;/code&gt;, every interface, including the public one. With it, Docker's DNAT rule only matches traffic that arrived on loopback, so nothing from outside the machine can reach it. This is the binding that closed the hole.&lt;/p&gt;

&lt;p&gt;The honest part: the same file still publishes other services on all interfaces.&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;api&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;8000:8000'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;          &lt;span class="c1"&gt;# 0.0.0.0, exposed&lt;/span&gt;
&lt;span class="na"&gt;storage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;9000:9000'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;9001:9001'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;   &lt;span class="c1"&gt;# object storage AND its admin console, exposed&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the database is fixed, but the API on 8000 and the MinIO admin console on 9001 are still published to the entire internet. Which leads to the better question.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real fix: internal services need no published ports at all
&lt;/h2&gt;

&lt;p&gt;Publishing a port is for traffic that comes from outside the machine. Look at what actually needs that: nothing except nginx. The reverse proxy already reaches every other service by name over the Docker network:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="c1"&gt;# nginx talks to the containers directly, over the internal network&lt;/span&gt;
&lt;span class="k"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://api:8000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://storage:9000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;nginx does not use the host-published &lt;code&gt;8000&lt;/code&gt; or &lt;code&gt;9000&lt;/code&gt;. It uses Docker's internal DNS and the shared network. Which means the &lt;code&gt;ports:&lt;/code&gt; entries on &lt;code&gt;api&lt;/code&gt;, &lt;code&gt;storage&lt;/code&gt;, and the dashboard are not just risky, they are pointless. Nothing outside the compose network uses them.&lt;/p&gt;

&lt;p&gt;So the fix for most services is to delete the port publishing entirely. The only container that should publish to the host is nginx, on 80 and 443. Everything else talks over the internal network, invisible from outside:&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;nginx&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&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="s1"&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="c1"&gt;# the only public surface&lt;/span&gt;
&lt;span class="na"&gt;api&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="c1"&gt;# no ports: block at all, nginx reaches it as http://api:8000 internally&lt;/span&gt;
&lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;127.0.0.1:5432:5432'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt; &lt;span class="c1"&gt;# loopback only, for local psql/backups&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you genuinely need a port reachable from outside but restricted (say, Postgres from one office IP), do not reach for &lt;code&gt;ufw&lt;/code&gt;. Put the rule in the &lt;code&gt;DOCKER-USER&lt;/code&gt; chain, the one iptables chain Docker guarantees it evaluates before its own rules and never flushes:&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;# only this source may reach forwarded container traffic on 5432&lt;/span&gt;
iptables &lt;span class="nt"&gt;-I&lt;/span&gt; DOCKER-USER &lt;span class="nt"&gt;-p&lt;/span&gt; tcp &lt;span class="nt"&gt;--dport&lt;/span&gt; 5432 &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; 203.0.113.10 &lt;span class="nt"&gt;-j&lt;/span&gt; DROP
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Audit from outside, never trust &lt;code&gt;iptables -L&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;The lesson underneath all of this: your firewall config is a claim, not a fact. &lt;code&gt;iptables -L&lt;/code&gt; and &lt;code&gt;ufw status&lt;/code&gt; show you what you intended, through the chain you were looking at. They will happily show 5432 as denied on &lt;code&gt;INPUT&lt;/code&gt; while it is wide open through &lt;code&gt;FORWARD&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The only source of truth is a scan from a machine that is not the server:&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;# from your laptop, not from the box&lt;/span&gt;
nmap &lt;span class="nt"&gt;-Pn&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; 5432,8000,9000,9001 your.server.ip
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If anything other than 80 and 443 comes back as &lt;code&gt;open&lt;/code&gt;, you have a service exposed that you probably did not mean to expose. That single command would have told me about the database months before an attacker did.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;Docker manages the firewall for you, and it does not consult the one you wrote. Publishing a port silently adds rules in &lt;code&gt;FORWARD&lt;/code&gt; and &lt;code&gt;nat&lt;/code&gt; that your &lt;code&gt;INPUT&lt;/code&gt; and &lt;code&gt;ufw&lt;/code&gt; rules never touch, so "the port is blocked" and "the port is open" can both be true at once. Bind anything you must publish to &lt;code&gt;127.0.0.1&lt;/code&gt;, publish nothing that nginx can proxy internally, use &lt;code&gt;DOCKER-USER&lt;/code&gt; for real restrictions, and verify with an external scan rather than a local list.&lt;/p&gt;

&lt;p&gt;Closing the ports is only half of perimeter security. The other half is what to do about the traffic that is still allowed to hit nginx: the constant scans and brute-force, and the day my TLS certificate silently failed to renew. That is a later article in this series.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://jguillaumesio.com/blog/docker-bypasses-your-firewall/" rel="noopener noreferrer"&gt;jguillaumesio.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>docker</category>
      <category>security</category>
      <category>devops</category>
      <category>linux</category>
    </item>
    <item>
      <title>The docker build that filled the disk and took down production</title>
      <dc:creator>jguillaumesio</dc:creator>
      <pubDate>Sun, 09 Aug 2026 09:26:20 +0000</pubDate>
      <link>https://dev.to/jguillaumesio/the-docker-build-that-filled-the-disk-and-took-down-production-3gff</link>
      <guid>https://dev.to/jguillaumesio/the-docker-build-that-filled-the-disk-and-took-down-production-3gff</guid>
      <description>&lt;p&gt;Production went down, and I had not shipped any code. No traffic spike, no bad migration, nothing in the application logs. The API just stopped answering, and so did every container on the box.&lt;/p&gt;

&lt;p&gt;The cause was mundane and entirely self-inflicted: the disk was full. And the thing that filled it was my own deploy process, building Docker images on the exact machine that serves customer traffic.&lt;/p&gt;

&lt;p&gt;This is part 1 of a series on hardening a solo-built SaaS in production. The setup and the full list of weak spots are in &lt;a href="https://jguillaumesio.com/blog/docker-compose-production-single-vps/" rel="noopener noreferrer"&gt;the pillar article&lt;/a&gt;. This one is about the first weak spot that bit me: building where you serve.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "deploy" meant that day
&lt;/h2&gt;

&lt;p&gt;There is no pipeline. A deploy is me, over SSH, running &lt;code&gt;make deploy&lt;/code&gt;. Stripped down, the target does this:&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;# Makefile, roughly&lt;/span&gt;
docker compose build api                      &lt;span class="c"&gt;# build ON the prod box&lt;/span&gt;
docker compose build &lt;span class="nt"&gt;--no-cache&lt;/span&gt; dashboard     &lt;span class="c"&gt;# and this one with no cache&lt;/span&gt;
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--force-recreate&lt;/span&gt; &lt;span class="nt"&gt;--no-build&lt;/span&gt;
docker image prune &lt;span class="nt"&gt;-f&lt;/span&gt;                          &lt;span class="c"&gt;# (added later, see below)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read that with fresh eyes and the problem is right there. Every deploy builds images on the production host. The build pulls base layers, compiles the app, and writes new image layers, all onto the same disk that Postgres, MinIO uploads, and the container logs are living on. The build is a heavy, bursty batch job sharing a disk with a latency-sensitive service that must never stop.&lt;/p&gt;

&lt;p&gt;But the resource contention was not what took prod down that day. Something quieter did.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the disk filled, and what cleared it
&lt;/h2&gt;

&lt;p&gt;Here is the mechanism I had not thought about. &lt;code&gt;docker compose build&lt;/code&gt; does not update an image in place. Each build produces a brand new image and moves the service's tag to it. The previous image loses its tag and becomes a dangling image, still on disk, still taking space, tagged &lt;code&gt;&amp;lt;none&amp;gt;&lt;/code&gt; and referenced by nothing.&lt;/p&gt;

&lt;p&gt;Deploy after deploy, those orphaned &lt;code&gt;&amp;lt;none&amp;gt;&lt;/code&gt; images piled up. The dashboard, built with &lt;code&gt;--no-cache&lt;/code&gt;, wrote a fresh full set every single time. Nothing was cleaning them, so they grew until the disk hit 100 percent and the kernel started refusing writes. Postgres cannot write, so the API errors, so everything falls over.&lt;/p&gt;

&lt;p&gt;The thing that saved me was exactly the command people underestimate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker image prune &lt;span class="nt"&gt;-f&lt;/span&gt;    &lt;span class="c"&gt;# removes dangling &amp;lt;none&amp;gt; images&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;docker image prune -f&lt;/code&gt; removes dangling images, and dangling &lt;code&gt;&amp;lt;none&amp;gt;&lt;/code&gt; images were precisely what had filled the disk. It reclaimed the space and brought production back in minutes. That is the whole reason that line is in my deploy today: I added the &lt;code&gt;docker image prune -f&lt;/code&gt; at the end of &lt;code&gt;make deploy&lt;/code&gt; after this incident, so the images the build orphans get cleared on every deploy instead of piling up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Diagnosing it: two commands
&lt;/h2&gt;

&lt;p&gt;When a Linux box misbehaves in a way the app logs cannot explain, check the disk before anything else:&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;df&lt;/span&gt; &lt;span class="nt"&gt;-h&lt;/span&gt;                    &lt;span class="c"&gt;# is the filesystem full? almost always the answer&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If Docker is the culprit, this is the command that shows you where the space went:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker system &lt;span class="nb"&gt;df&lt;/span&gt;         &lt;span class="c"&gt;# summary: images, containers, volumes, build cache&lt;/span&gt;
docker system &lt;span class="nb"&gt;df&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt;      &lt;span class="c"&gt;# per-item breakdown, marks reclaimable space&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;-v&lt;/code&gt; view is the one that matters. It lists images individually and splits "active" from "reclaimable". The reclaimable images, the dangling &lt;code&gt;&amp;lt;none&amp;gt;&lt;/code&gt; ones, were the space my deploys had been leaking. Seeing that stack of untagged images is what told me the deploy, not the data, had filled the disk.&lt;/p&gt;

&lt;h2&gt;
  
  
  One dangerous reflex to avoid
&lt;/h2&gt;

&lt;p&gt;If the build cache has also grown, you can reclaim more, but be precise, because the aggressive commands can delete more than you mean to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker image prune &lt;span class="nt"&gt;-af&lt;/span&gt;      &lt;span class="c"&gt;# all unused images, tagged or not&lt;/span&gt;
docker builder prune &lt;span class="nt"&gt;-f&lt;/span&gt;      &lt;span class="c"&gt;# the build cache&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What I did &lt;strong&gt;not&lt;/strong&gt; run is &lt;code&gt;docker system prune --volumes&lt;/code&gt;. That flag also removes volumes. On this setup the data lives in bind mounts rather than named volumes, so it would not have been caught, but the reflex is dangerous: on a box that does use named volumes, that one flag deletes your database. Never reach for &lt;code&gt;--volumes&lt;/code&gt; on a production machine you have not audited. (That the data was in bind mounts with no backup is its own problem, and its own article later in this series.)&lt;/p&gt;

&lt;h2&gt;
  
  
  The one good habit already in place
&lt;/h2&gt;

&lt;p&gt;Credit where it is due: the log side of this was already handled. Every service in the compose file caps its logs:&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;logging&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;driver&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;json-file&lt;/span&gt;
  &lt;span class="na"&gt;options&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;max-size'&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;10m'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;max-file'&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;3'&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without that, Docker's default json-file logs grow unbounded and are a classic way to fill a disk on their own. So the logs were not the problem. The orphaned images were. It is worth saying because "the disk is full" has several possible causes, and capping logs removes one of them cleanly. If your compose file does not do this, add it now.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real fix: stop building where you serve
&lt;/h2&gt;

&lt;p&gt;Auto-pruning after every deploy stops the disk from filling again, and it is the right immediate fix. But it patches one symptom of a deeper problem: I am still building images on the machine that serves customers. That has costs the prune does not touch. The build competes with live traffic for CPU, RAM, and disk while it runs. A deploy that fails before it reaches the prune line still leaves its garbage behind. And &lt;code&gt;--force-recreate&lt;/code&gt; still means a few seconds of downtime on every deploy.&lt;/p&gt;

&lt;p&gt;The clean answer is to take the build off the box entirely: build the image in CI, push it to a registry, and have the server only ever &lt;code&gt;pull&lt;/code&gt; a finished image. The prod machine goes back to doing one job, running containers, and never sees a build again. That is a bigger change (it comes with banning direct pushes to &lt;code&gt;main&lt;/code&gt; and an actual deploy pipeline), so it gets its own article later in this series.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;The outage was my deploy process leaving orphaned &lt;code&gt;&amp;lt;none&amp;gt;&lt;/code&gt; images on the machine it deployed to, one per build, until the disk was full. &lt;code&gt;docker image prune -f&lt;/code&gt; clears exactly those, and wiring it into every deploy is the five-minute fix everyone building with Compose should have. But the deeper lesson is that a production box should not be building images at all.&lt;/p&gt;

&lt;p&gt;Check &lt;code&gt;docker system df -v&lt;/code&gt; right now. If you see a stack of dangling &lt;code&gt;&amp;lt;none&amp;gt;&lt;/code&gt; images, your deploys are leaking disk, and a prune (ideally automatic) is one command away. The clean version of the fix, build in CI and pull on the server, is coming up in this series.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://jguillaumesio.com/blog/docker-build-filled-the-disk/" rel="noopener noreferrer"&gt;jguillaumesio.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>docker</category>
      <category>linux</category>
      <category>sre</category>
    </item>
    <item>
      <title>CPU utilization lies: autoscaling a single-threaded service</title>
      <dc:creator>jguillaumesio</dc:creator>
      <pubDate>Sun, 09 Aug 2026 09:24:26 +0000</pubDate>
      <link>https://dev.to/jguillaumesio/cpu-utilization-lies-autoscaling-a-single-threaded-service-539k</link>
      <guid>https://dev.to/jguillaumesio/cpu-utilization-lies-autoscaling-a-single-threaded-service-539k</guid>
      <description>&lt;p&gt;The service was slow. Not down, just slow: p95 latency climbing well past where users notice, requests piling up, the kind of degradation that generates support tickets instead of alerts. And the autoscaler, the whole point of which is to add capacity when a service is under strain, sat there doing nothing.&lt;/p&gt;

&lt;p&gt;The metric it was watching said everything was fine. Average CPU utilization on the tasks was hovering around 30 percent, nowhere near the scale-out threshold. The dashboard was calm. The users were not. Both were right, and the gap between them is one of the most common autoscaling traps on a container platform.&lt;/p&gt;

&lt;p&gt;This is the first article in a series on running a multi-tenant SaaS on AWS at team scale. It is about a metric that lies, quietly, by design.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why 30 percent CPU meant 100 percent busy
&lt;/h2&gt;

&lt;p&gt;The service was a single-threaded application. A Node.js API, in this case, but the same is true of any process that does its real work on one thread: a classic Python or Ruby worker, most single-process runtimes.&lt;/p&gt;

&lt;p&gt;A single-threaded process can, by definition, saturate exactly one CPU core. The task it was running on had four vCPUs. So the arithmetic that matters is brutally simple:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;one core fully pegged / four vCPUs on the task = ~25% task-average CPU
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At full saturation, the busiest that process can ever make the task &lt;em&gt;look&lt;/em&gt; is about 25 percent. Add a little async I/O overhead spread across the runtime and you land around 30 percent. That is not a service with headroom. That is a service redlining on the only core it can use, while three cores sit idle and drag the average down to a number that reads as "barely working."&lt;/p&gt;

&lt;p&gt;The autoscaling policy was tracking average CPU across the task's cores. For a workload that can only ever use one of them, that average is not a measure of load. It is a measure of load divided by four.&lt;/p&gt;

&lt;h2&gt;
  
  
  The metric was answering a different question
&lt;/h2&gt;

&lt;p&gt;This is the real lesson, and it is not specific to AWS or ECS. Average CPU utilization answers "how much of the machine's total compute is in use." Autoscaling needs the answer to a different question: "is this service keeping up with its work."&lt;/p&gt;

&lt;p&gt;For a multi-threaded service that spreads across all its cores, those two questions have nearly the same answer, which is why CPU is the default scaling metric and why it usually works. For a single-threaded service, they diverge completely. The service can be fully saturated and failing its latency targets while the CPU metric, honestly and correctly, reports 30 percent.&lt;/p&gt;

&lt;p&gt;Scaling on the wrong metric is worse than not scaling, because it comes with a dashboard that actively reassures you. Everything looks healthy right up until a human notices it is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three ways to fix it
&lt;/h2&gt;

&lt;p&gt;There is a quick lever and a couple of durable ones.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Size the task to one vCPU.&lt;/strong&gt; If the process can only use one core, stop giving it four. On a one-vCPU task, "one core saturated" is 100 percent CPU, and the autoscaler can finally see the truth. This is the cheapest fix and it doubles as a cost cleanup: you were paying for three idle cores per task. Standardising the fleet to one vCPU per service is not a downgrade here, it is making the reported metric mean something again.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scale on a signal that reflects the actual constraint.&lt;/strong&gt; The workload is latency-bound, so scale on latency or on request pressure, not CPU. On this stack that means target-tracking on the load balancer's target response time, or on request count per target, rather than CPU utilization. A custom metric works too: for an event-loop runtime, event-loop lag is a direct measure of "this process is behind." Scale on the thing that hurts, not on a proxy that averages it away.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Run one worker per core if you must use big tasks.&lt;/strong&gt; If a task really needs four vCPUs, then run four workers on it (cluster mode, one process per core, a process manager, or four smaller tasks instead of one big one). Now all four cores can be busy, and average CPU becomes an honest metric again. The rule underneath: CPU utilization is only meaningful when your app can actually use all the cores it is measured against.&lt;/p&gt;

&lt;p&gt;The immediate mitigation, while you decide, is to scale out by hand: add tasks and watch latency. If more tasks fix the latency, you have just confirmed the service was capacity-starved all along, and you can lower the scaling threshold (or switch metrics) with confidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;Autoscaling is only as good as the metric it watches, and the default metric quietly assumes your app uses every core it is given. A single-threaded service breaks that assumption: it maxes out one core, reports a fraction of the task's total CPU, and sails under a CPU-based scale-out threshold while its latency falls apart.&lt;/p&gt;

&lt;p&gt;Match the metric to how the app actually consumes resources. Size single-threaded tasks so one core is the whole task and CPU tells the truth, or scale on latency and request pressure instead. And treat a calm dashboard next to unhappy users as a bug in your monitoring, not a coincidence. The metric that says everything is fine is the first thing to distrust.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://jguillaumesio.com/blog/autoscaling-single-threaded-cpu-lies/" rel="noopener noreferrer"&gt;jguillaumesio.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>devops</category>
      <category>node</category>
      <category>performance</category>
    </item>
    <item>
      <title>A practical playbook for running a remote dev team</title>
      <dc:creator>jguillaumesio</dc:creator>
      <pubDate>Thu, 09 Jul 2026 16:00:34 +0000</pubDate>
      <link>https://dev.to/jguillaumesio/a-practical-playbook-for-running-a-remote-dev-team-p3a</link>
      <guid>https://dev.to/jguillaumesio/a-practical-playbook-for-running-a-remote-dev-team-p3a</guid>
      <description>&lt;p&gt;We are eight people, fully remote: one lead, four developers, two product (one of them a designer), and one QA. We build a multi-tenant product: one shared codebase, plus features specific to individual tenants.&lt;/p&gt;

&lt;p&gt;For a while it did not work. Bugs reached production regularly. We picked up work ad-hoc. Nobody had a clear picture of what was shipping next. And one QA cannot cover four developers on a codebase where a change for one client can quietly break another.&lt;/p&gt;

&lt;p&gt;Then a new lead arrived. He did not rewrite our process from a book. He introduced five ceremonies, one at a time, each one aimed at a specific thing that was hurting us. This is the playbook: what each ceremony is, exactly how we run it, and what changed.&lt;/p&gt;

&lt;p&gt;One honest caveat up front: we do not run fixed-length sprints. When I say "sprint review" below, read it as "release review." We run regular ceremonies over a continuous flow of work, not textbook Scrum. I think that is fine, and I will come back to it at the end.&lt;/p&gt;




&lt;h2&gt;
  
  
  Ship fewer bugs: put quality in the ticket, not just in QA
&lt;/h2&gt;

&lt;p&gt;The worst bugs we shipped were per-tenant: a change for one client broke another. You cannot hire your way out of that fast enough. The only thing that scales is moving quality earlier, before a line of code is written. The industry name is "shift left," and the numbers are blunt: ambiguous or incomplete requirements are blamed for an estimated 50 to 60% of production defects, and a bug caught at the requirements stage costs a fraction of the same bug caught in production.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Requirements-phase defects (ambiguous acceptance criteria, missing edge cases, contradictory business rules) account for an estimated 50 to 60% of production defects, and teams that shift QA left reduce defect costs dramatically compared to teams that catch bugs in production.&lt;/p&gt;

&lt;p&gt;Source: synthesised from IBM's Systems Sciences Institute data and Capgemini's &lt;a href="https://www.practicalengineering.management/p/leading-qa-shift-left-transformation-740" rel="noopener noreferrer"&gt;World Quality Report&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So we changed what a ticket must contain before anyone picks it up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every ticket gets acceptance criteria before it is estimated.&lt;/strong&gt; Not a vague title. A concrete list of what must be true for the ticket to be done.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If a ticket is unclear, it gets an investigation first.&lt;/strong&gt; We do not estimate something nobody understands. A short spike answers the open questions, and only then do we size it. This alone killed a class of "we did not realise it also touched X" bugs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The multi-tenant rule: every ticket names the blast radius.&lt;/strong&gt; Because our worst failures were cross-tenant, each ticket answers one question explicitly: does this touch shared code or a single tenant, and if shared, which tenants need checking? That one line turns an invisible risk into a checklist item.&lt;/p&gt;

&lt;p&gt;Here is a template we paste into every non-trivial ticket:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gu"&gt;## Acceptance criteria&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; [ ] Given &lt;span class="nt"&gt;&amp;lt;state&amp;gt;&lt;/span&gt;, when &lt;span class="nt"&gt;&amp;lt;action&amp;gt;&lt;/span&gt;, then &lt;span class="nt"&gt;&amp;lt;result&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; [ ] Negative case: given &lt;span class="nt"&gt;&amp;lt;bad&lt;/span&gt; &lt;span class="na"&gt;input&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;, then &lt;span class="nt"&gt;&amp;lt;handled&lt;/span&gt; &lt;span class="na"&gt;how&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;

&lt;span class="gu"&gt;## Blast radius&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Touches: [ ] shared core   [ ] single tenant
&lt;span class="p"&gt;-&lt;/span&gt; If shared, tenants to verify: &lt;span class="nt"&gt;&amp;lt;list&amp;gt;&lt;/span&gt;

&lt;span class="gu"&gt;## How to test&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="nt"&gt;&amp;lt;steps&lt;/span&gt; &lt;span class="na"&gt;QA&lt;/span&gt; &lt;span class="na"&gt;can&lt;/span&gt; &lt;span class="na"&gt;follow&lt;/span&gt; &lt;span class="na"&gt;without&lt;/span&gt; &lt;span class="na"&gt;asking&lt;/span&gt; &lt;span class="na"&gt;us&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here is the part we have not fully solved, and I would rather be honest about it. Today the &lt;strong&gt;ticket creator&lt;/strong&gt; writes the acceptance criteria, and we developers write the "how to test" notes. That works until it does not: sometimes those notes are thin, the ticket gets marked ready for release, and it should not have been. The root cause is not who typed the notes. It is that the &lt;strong&gt;author is effectively self-certifying&lt;/strong&gt;. The person who wrote the code is the last person who should be the gate that says "this is releasable."&lt;/p&gt;

&lt;p&gt;Where I think this goes next is a lighter version of what the industry calls the "Three Amigos": product, QA, and a developer shaping the criteria together, product owning the "what," QA owning "what could break," dev owning "what it touches." We do not run that ceremony yet. But even a lightweight Definition of Ready would help, and the release gate should be someone other than the author, QA when possible, a peer when QA is swamped.&lt;/p&gt;

&lt;p&gt;Here is the checklist we are converging on, tick it against your own next ticket:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Acceptance criteria written and understood before estimation&lt;/li&gt;
&lt;li&gt;Blast radius named: shared core or single tenant, and which tenants to verify&lt;/li&gt;
&lt;li&gt;"How to test" steps a non-author can follow without asking questions&lt;/li&gt;
&lt;li&gt;Release gate is someone other than the author&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The regression net: the same cases, tested before every release
&lt;/h3&gt;

&lt;p&gt;Acceptance criteria stop new bugs. They do nothing for the old feature that silently breaks when you touch shared code. For a multi-tenant product that is the most dangerous failure of all, and it needs its own guard: a fixed set of checks that runs before every release, not per ticket.&lt;/p&gt;

&lt;p&gt;There are two acceptable versions of this, and you should be climbing from the second toward the first:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Automated end-to-end tests&lt;/strong&gt; covering the critical paths: login, the core workflow, billing, and the per-tenant features that diverge from the shared core. Green suite or no release.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;At minimum, a written list of must-pass scenarios&lt;/strong&gt;, one set per important client, that QA runs before every release. Nothing ships until that list is green.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The multi-tenant rule applies here too: the list must include at least one scenario for every tenant whose behaviour differs from the shared core, because that is exactly where a "harmless" change detonates. And the list is a ratchet: the moment a regression slips through, its scenario gets added, so the same bug can never reach production twice.&lt;/p&gt;

&lt;p&gt;This is the difference between "we tested the thing we changed" and "we proved we did not break the things we did not touch." Only the second one keeps a multi-tenant product stable as it grows.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What changed anyway:&lt;/strong&gt; more tickets are genuinely ready for release, because more eyes hit them before they get there. The remaining gap is the self-certification one above, and naming it is the first step to closing it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The retro: a Miro board, and votes that force focus
&lt;/h2&gt;

&lt;p&gt;At the end of each release we open a Miro board with four columns:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;What is good&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;What we learned&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;What is not going well&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;What we would want&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everyone adds notes, privately, at the same time. Writing before talking is the whole trick: it avoids the loudest voice setting the agenda and it avoids groupthink. Then everyone gets a small number of votes (we use three) to spend on the topics they most want to discuss. We only discuss the top-voted items. The rest are logged, not lost.&lt;/p&gt;

&lt;p&gt;The retro is where every other ceremony on this page actually got tuned. It is also the ceremony most likely to become theatre. The single most common way retros fail is not the format, it is the follow-through:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The reason most retrospectives fail is not the meeting itself, it is what happens (or does not happen) afterwards. Action items get discussed, then forgotten by the next sprint.&lt;/p&gt;

&lt;p&gt;Source: Easy Agile, &lt;a href="https://www.easyagile.com/blog/improve-sprint-retrospective-action-items" rel="noopener noreferrer"&gt;Why Retrospectives Fail&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This is exactly where we are weakest, so I will not pretend otherwise. We discuss the top items and we track them loosely, but we do not assign a named owner or turn each one into a Jira ticket. So some drift, and the same complaint resurfaces a few retros later. The fix we know we need is boring: two or three action items maximum, each with an owner and a ticket, revisited at the start of the next retro. An action with no owner did not really happen. We are not there yet, and writing it down here is partly to shame ourselves into it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Planning poker: the size is disposable
&lt;/h2&gt;

&lt;p&gt;We size tickets with &lt;a href="https://www.planitpoker.com/" rel="noopener noreferrer"&gt;planITpoker&lt;/a&gt;, but with one deliberate change: we do not vote in numbers. We vote in t-shirt sizes, XS, S, M, L, XL. Numbers invite false precision (is it really a 5 and not an 8?) and, worse, they invite someone to add them up into a velocity number and wave it around. Sizes resist both. "This is a Large" starts a conversation. "This is 8 points" starts a spreadsheet.&lt;/p&gt;

&lt;p&gt;Everyone votes at the same time, hidden, then we reveal together. The blind reveal is the point: it kills anchoring, so a junior does not just echo the senior's card.&lt;/p&gt;

&lt;p&gt;A wide spread, S next to XL, is not a failure of the estimate, that is the estimate doing its job. When the sizes disagree, someone knows something the others do not, a hidden dependency, an edge case, a tenant that behaves differently. The conversation that follows is the actual deliverable.&lt;/p&gt;

&lt;p&gt;I want to be fair to the critics here, because they are the reason we dropped numbers in the first place. There is a loud, credible backlash against story points:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;I don't like story points because they give a false sense of precision, and the moment management starts treating them as a productivity metric, they are weaponised against the very developers who produce them.&lt;/p&gt;

&lt;p&gt;Source: paraphrasing Tomasz Lakomy, &lt;a href="https://dev.to/tlakomy/why-i-don-t-like-story-point-driven-estimates-28h7"&gt;Why I don't like story-point-driven estimates&lt;/a&gt;, and Scott Logic, &lt;a href="https://blog.scottlogic.com/2024/07/05/story-points-are-wasting-time.html" rel="noopener noreferrer"&gt;Story points are wasting time&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;They are right about the failure mode. Points become poison the instant a manager turns them into a burndown scoreboard. T-shirt sizes are our defence: there is nothing to sum, nothing to chart, nothing to weaponise. &lt;strong&gt;The size is disposable, the discussion is the deliverable.&lt;/strong&gt; We use the vote to trigger a conversation, and then we mostly forget the letter.&lt;/p&gt;

&lt;h2&gt;
  
  
  The daily: coordination, not a status report
&lt;/h2&gt;

&lt;p&gt;Fifteen minutes, same time every day, everyone on camera. Here is what it is for, and what it is not.&lt;/p&gt;

&lt;p&gt;It is not a status report to the lead. The dailies people rightly hate are the ones where each person recites yesterday's tasks to a manager who writes them down. If that is all your daily is, it should have been an async written update, a Slack message. The critics are right about that. Keep the synchronous time for the blockers. So that is what each person actually covers: what is blocking me, and what I am about to touch, especially if it is shared code. Not a recital of yesterday's tickets.&lt;/p&gt;

&lt;p&gt;Being fully remote, we felt the pull toward async written standups, and for pure status they would be better. But we kept the live daily for one reason it does well: &lt;strong&gt;coordination and surfacing blockers in real time.&lt;/strong&gt; It is where "I am about to touch the shared billing code" meets "wait, so am I." That collision does not happen in a threaded update. So the rule is: keep it short, no solving problems in the meeting, take the deep dives to a breakout right after.&lt;/p&gt;

&lt;p&gt;Less ad-hoc drift is one of the payoffs, not the biggest but a real one: because we sync every morning, fewer people quietly start random work, and everyone has a rough idea of what is being picked up. The bigger win is still the collisions we avoid by saying out loud what we are about to touch.&lt;/p&gt;

&lt;h2&gt;
  
  
  The sprint review: shared visibility, not a demo
&lt;/h2&gt;

&lt;p&gt;Call it a release review in our case, since we do not run fixed sprints. The trap everyone falls into is treating this as a demo: a parade of every task, performed for an audience. Ours has no external audience at all, no clients, just the developers, product, and QA, which is exactly why we can optimise it for shared understanding instead of performance. Scrum's own guidance pushes back on the demo trap too:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The Sprint Review is not a demo. It is a working session to inspect the outcome and figure out what to do next. If it is a one-way presentation, you are doing it wrong.&lt;/p&gt;

&lt;p&gt;Source: Scrum.org, &lt;a href="https://www.scrum.org/resources/blog/sprint-review-much-more-just-demo" rel="noopener noreferrer"&gt;Sprint Review: Much More Than Just A Demo&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;For us the real value is not the demo at all, it is shared visibility. We walk the whole internal team through what is going into the next release and what is queued behind it. Three things fall out of that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Everyone learns the whole product.&lt;/strong&gt; Because we discuss many tickets, including ones we will not personally pick up, more people understand more of the system. New joiners ramp faster.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fewer PRs stuck in the review queue.&lt;/strong&gt; When everyone already has context on a change, reviews are quick instead of archaeological.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;We catch duplicates and collisions.&lt;/strong&gt; Discussing tickets together is when someone says "that is the same as the thing I did last month" or "those two will conflict."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We do keep a separate slot for real demos: a Friday session you sign up for to show something you shipped. Honestly, it is our weakest ceremony. It tends to run long, not many people sign up, and half the time the interesting work never gets shown. The visibility review above is what earns its keep. The demo is still looking for its format.&lt;/p&gt;

&lt;p&gt;None of this is clever. The reason shared visibility beats a polished demo is that the demo optimises for looking productive to outsiders, while the review optimises for the team actually knowing what is happening. The first is theatre. The second is the point.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual lesson
&lt;/h2&gt;

&lt;p&gt;None of these five ceremonies is novel. You have heard of all of them. The thing worth copying is not the ceremonies, it is that each one was introduced to fix a specific, named pain:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Bugs in prod → &lt;strong&gt;acceptance criteria and blast radius in the ticket.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Ad-hoc chaos → &lt;strong&gt;the daily as coordination.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Estimates nobody trusted → &lt;strong&gt;blind planning poker in t-shirt sizes, with the size thrown away.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;No shared picture → &lt;strong&gt;the review as visibility, not demo.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Everything drifting → &lt;strong&gt;the retro, with owned action items.&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We do not do textbook Scrum. No fixed sprints, no formal Three Amigos, and one honest gap where authors still self-certify their own releases. That is fine. The goal was never to run the ceremonies correctly. It was to stop shipping bugs and start knowing what the team is doing. Pick the pain you actually have, and add the one ceremony that targets it. Then run the retro, and let it tell you what to add next.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://jguillaumesio.com/blog/remote-team-playbook/" rel="noopener noreferrer"&gt;jguillaumesio.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>productivity</category>
      <category>agile</category>
      <category>teamwork</category>
      <category>remote</category>
    </item>
    <item>
      <title>The cart timer expired while they were paying. Now what?</title>
      <dc:creator>jguillaumesio</dc:creator>
      <pubDate>Fri, 03 Jul 2026 12:02:39 +0000</pubDate>
      <link>https://dev.to/jguillaumesio/the-cart-timer-expired-while-they-were-paying-now-what-25gh</link>
      <guid>https://dev.to/jguillaumesio/the-cart-timer-expired-while-they-were-paying-now-what-25gh</guid>
      <description>&lt;p&gt;Here is a bug report I have received in some form at three different companies. The customer opens the payment widget, enters their card, and hits pay. While the bank is doing its 3-D Secure dance, the cart's countdown timer hits zero. The frontend, doing exactly what it was told, tears down the session. The payment succeeds a second later against a cart that no longer exists. Now there is money in the account and no order attached to it.&lt;/p&gt;

&lt;p&gt;Every engineer who built that timer built it correctly. The countdown protects a limited resource: a concert seat, a hotel night, the last unit in stock. Holding it forever would let one abandoned tab starve every other customer. So you set a timer, and when it fires you release the hold. Clean, defensible, and completely blind to the fact that a real person had already committed their money.&lt;/p&gt;

&lt;p&gt;That blindness is the actual subject here. The countdown-expiry bug is small. The habit that produces it is not.&lt;/p&gt;




&lt;h2&gt;
  
  
  The timer answers a question nobody asked at that moment
&lt;/h2&gt;

&lt;p&gt;Step back and ask what the countdown is &lt;em&gt;for&lt;/em&gt;. It exists to resolve contention: two people want the same seat, and the hold decides who gets first refusal. That is the only job. It is a fairness mechanism between customers who have &lt;strong&gt;not&lt;/strong&gt; yet paid.&lt;/p&gt;

&lt;p&gt;The moment a customer enters the payment flow, the question the timer answers stops being relevant. Contention is over. This person is not browsing, not hesitating, not sitting on a tab they forgot about. They are actively handing you money for the exact thing the hold was protecting. Firing the timer now does not serve any other customer, because no other customer can be served: the seat is about to be sold. It only serves the abstraction.&lt;/p&gt;

&lt;p&gt;This is the tell. An engineer thinking in systems sees a timer that reached zero and a rule that says "release on zero". An engineer thinking about the product sees a person who did everything right and is about to be punished for the bank's latency. Same event, two completely different readings, and only one of them keeps the customer.&lt;/p&gt;




&lt;h2&gt;
  
  
  So, do you let them finish?
&lt;/h2&gt;

&lt;p&gt;Yes. Almost always, yes. If the payment authorizes, honor it.&lt;/p&gt;

&lt;p&gt;The reasoning is not sentimental. A successful authorization is the strongest possible signal of intent, far stronger than the "still holding" state the timer was guarding. Rejecting a payment you already accepted creates the worst outcome for everyone: you now have to refund, the customer sees a charge-then-refund cycle that reads as "this company is sketchy", and you have burned trust to enforce a rule that protected nobody. Forcing that refund by letting a timer kill a live payment is a self-inflicted wound.&lt;/p&gt;

&lt;p&gt;The one case where you genuinely cannot honor it is true oversell: while this customer was in 3-D Secure, the last unit actually went to someone who completed faster. That is a real conflict, not a timer artifact. Handle it as an inventory failure with an immediate, automatic reversal and an honest message, not as a "your time ran out" error. The customer's experience should be "we were one second too slow and here is your money back instantly", never "you were too slow".&lt;/p&gt;




&lt;h2&gt;
  
  
  The two follow-up questions
&lt;/h2&gt;

&lt;p&gt;Once you accept that a live payment should win, two design questions follow, and they are where most implementations quietly go wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do you re-lock the offer right after they paid?&lt;/strong&gt; No. This one trips people up because the instinct is to keep the state machine consistent: the hold expired, so on the way out we should re-acquire it. But re-locking something the customer already bought is nonsense. They own it now. Payment is the terminal state, not another step that needs its slot reserved. If your code path re-locks after capture, you have a state machine that does not know the transaction is over, and that will produce its own class of bugs (double-holds, phantom availability) down the line.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do you run a separate timer for the final step?&lt;/strong&gt; Yes, and this is the actual fix. The browsing countdown and the payment window are two different clocks measuring two different things. The cart timer manages contention while the customer decides. The moment they commit to paying, you switch to a &lt;em&gt;payment grace window&lt;/em&gt;: a separate, more generous timer whose only job is to give the authorization time to resolve. Bank redirects, 3-D Secure, wallet confirmations, slow networks: a 30-second cart hold that felt urgent while browsing is absurd once someone is staring at their banking app's confirmation screen. Give the final step its own clock, sized for how long a real payment actually takes.&lt;/p&gt;




&lt;h2&gt;
  
  
  Make expiry server-authoritative
&lt;/h2&gt;

&lt;p&gt;The bug at the top of this article only reaches production when the timer lives in the browser and the browser is allowed to decide the session is dead. A frontend countdown is a display. It should never be the thing that releases inventory.&lt;/p&gt;

&lt;p&gt;Put the authority on the server. The hold has an expiry timestamp the server owns. The frontend counts down for the human, but the decision to release is a server-side check that runs when it actually matters: when someone else tries to grab the seat, or when the payment webhook arrives. When the capture webhook lands, the server compares it against the hold and the inventory, and decides. A visual timer hitting zero is not an event that should mutate anything. It is a hint to the user, nothing more.&lt;/p&gt;

&lt;p&gt;That single architectural move, holds expire on the server, the frontend only displays, eliminates the entire category. The countdown can hit zero and reset to "processing your payment" without ever touching the underlying reservation, because the reservation was never the frontend's to release.&lt;/p&gt;




&lt;h2&gt;
  
  
  The habit behind the bug
&lt;/h2&gt;

&lt;p&gt;None of the above is hard. The reason it ships broken so often is not technical difficulty, it is that the timer was built as a pure systems problem. Resource, contention, TTL, release. Every line of that is correct and the sum of it fails a customer, because the model never included the customer's experience of time. To the system, zero is zero. To the person, they were three-quarters of the way through paying.&lt;/p&gt;

&lt;p&gt;This is the engineer-versus-product gap, and it does not show up in the happy path. It shows up in exactly these seams: what happens when two clocks disagree, when the network is slow, when the user does the right thing at the wrong instant. Those edge cases are where the product actually lives, and they are the first thing a systems-only mindset drops, because from inside the system they look like correctly handled states, not like a human being told "too late" for something they already did.&lt;/p&gt;

&lt;p&gt;The fix is not "care more". It is to make one question part of the definition of done for anything user-facing: &lt;em&gt;what does the person on the other end experience when this fires?&lt;/em&gt; Ask it of the timer and you invent the grace window on your own. Skip it and you ship a state machine that is right about everything except the one thing it was for.&lt;/p&gt;




&lt;h2&gt;
  
  
  The rule I follow now
&lt;/h2&gt;

&lt;p&gt;Any timer, lock, or expiry that a user can be actively working against needs two things: a server that owns the real deadline, and an answer to "what happens to someone mid-action when this fires". A countdown that can cancel a payment already in flight has neither. Give the final step its own clock, keep the authority on the server, and let a committed customer finish. The abstraction does not need protecting. The person does.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://jguillaumesio.com/blog/checkout-timer-expired-mid-payment/" rel="noopener noreferrer"&gt;jguillaumesio.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>ux</category>
      <category>architecture</category>
      <category>ecommerce</category>
    </item>
    <item>
      <title>Multiple accounts in Claude Code: the complete setup</title>
      <dc:creator>jguillaumesio</dc:creator>
      <pubDate>Thu, 25 Jun 2026 19:54:15 +0000</pubDate>
      <link>https://dev.to/jguillaumesio/multiple-accounts-in-claude-code-the-complete-setup-10ek</link>
      <guid>https://dev.to/jguillaumesio/multiple-accounts-in-claude-code-the-complete-setup-10ek</guid>
      <description>&lt;p&gt;You have a personal Claude Code subscription and a work one. Or you freelance for two clients who each provide their own API key. Or you want a sandboxed account for experiments that won't pollute your main config.&lt;/p&gt;

&lt;p&gt;Whatever the reason, Claude Code doesn't ship with a built-in "switch account" command. But the architecture makes it straightforward: everything lives in a single config directory, and you can redirect it.&lt;/p&gt;




&lt;h2&gt;
  
  
  How Claude Code stores state
&lt;/h2&gt;

&lt;p&gt;Claude Code keeps all its state in &lt;code&gt;~/.claude&lt;/code&gt; by default. That includes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Authentication tokens&lt;/li&gt;
&lt;li&gt;Project-level settings (&lt;code&gt;.claude/&lt;/code&gt; inside each repo)&lt;/li&gt;
&lt;li&gt;Conversation history&lt;/li&gt;
&lt;li&gt;Memory files&lt;/li&gt;
&lt;li&gt;MCP server configs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The key insight: the &lt;strong&gt;&lt;code&gt;CLAUDE_CONFIG_DIR&lt;/code&gt;&lt;/strong&gt; environment variable overrides where Claude Code looks for all of this. Point it somewhere else, and you get a completely independent instance.&lt;/p&gt;




&lt;h2&gt;
  
  
  Basic setup: shell aliases
&lt;/h2&gt;

&lt;p&gt;Create one config directory per account:&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;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; ~/.claude-personal
&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; ~/.claude-work
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Find your Claude binary path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;which claude
&lt;span class="c"&gt;# e.g. /Users/you/.nvm/versions/node/v22.15.0/bin/claude&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add aliases to your &lt;code&gt;~/.zshrc&lt;/code&gt; (or &lt;code&gt;~/.bashrc&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;alias &lt;/span&gt;claude-personal&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'CLAUDE_CONFIG_DIR=~/.claude-personal claude'&lt;/span&gt;
&lt;span class="nb"&gt;alias &lt;/span&gt;claude-work&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'CLAUDE_CONFIG_DIR=~/.claude-work claude'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reload your shell:&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;source&lt;/span&gt; ~/.zshrc
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then authenticate each one separately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;claude-personal   &lt;span class="c"&gt;# run /login inside the session&lt;/span&gt;
claude-work       &lt;span class="c"&gt;# run /login inside the session&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each alias now opens Claude Code with its own auth, memory, and settings.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the alias approach misses
&lt;/h2&gt;

&lt;p&gt;The alias trick works, but it has gaps that will bite you in production use.&lt;/p&gt;

&lt;h3&gt;
  
  
  Problem 1: NVM version upgrades break absolute paths
&lt;/h3&gt;

&lt;p&gt;If you hardcode the binary path in your alias (as many guides suggest), upgrading Node via NVM silently breaks it. Use just &lt;code&gt;claude&lt;/code&gt; instead of the absolute path, and let &lt;code&gt;$PATH&lt;/code&gt; resolve it:&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;# fragile&lt;/span&gt;
&lt;span class="nb"&gt;alias &lt;/span&gt;claude-work&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'CLAUDE_CONFIG_DIR=~/.claude-work /Users/you/.nvm/versions/node/v22.15.0/bin/claude'&lt;/span&gt;

&lt;span class="c"&gt;# resilient&lt;/span&gt;
&lt;span class="nb"&gt;alias &lt;/span&gt;claude-work&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'CLAUDE_CONFIG_DIR=~/.claude-work claude'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Problem 2: project-level config leaks between accounts
&lt;/h3&gt;

&lt;p&gt;Claude Code creates a &lt;code&gt;.claude/&lt;/code&gt; directory inside your project repo. That directory stores project settings, &lt;code&gt;CLAUDE.md&lt;/code&gt;, and &lt;code&gt;settings.json&lt;/code&gt;. These are shared across all your aliases because they live in the repo, not in the config dir.&lt;/p&gt;

&lt;p&gt;This means your work account and personal account see the same project-level instructions. That's usually fine, but if you need different MCP servers or permissions per account per project, you'll need to handle it differently (see the wrapper script section below).&lt;/p&gt;

&lt;h3&gt;
  
  
  Problem 3: hooks and MCP servers are per-config-dir
&lt;/h3&gt;

&lt;p&gt;If you've configured custom hooks or MCP servers in &lt;code&gt;~/.claude/settings.json&lt;/code&gt;, those won't exist in your new config directories. You'll need to copy or symlink the parts you want shared:&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;# share hooks across accounts&lt;/span&gt;
&lt;span class="nb"&gt;ln&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; ~/.claude/settings.json ~/.claude-work/settings.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or, if you want different hooks per account, copy and customize:&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;cp&lt;/span&gt; ~/.claude/settings.json ~/.claude-work/settings.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Problem 4: memory doesn't transfer
&lt;/h3&gt;

&lt;p&gt;Each config directory has its own memory system. Your personal account won't remember what your work account learned. If you use memory-heavy workflows, that isolation is sometimes a feature, sometimes a problem.&lt;/p&gt;




&lt;h2&gt;
  
  
  Better approach: a wrapper script
&lt;/h2&gt;

&lt;p&gt;Instead of simple aliases, a small wrapper gives you account switching with validation:&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="c"&gt;# Save as ~/bin/claude-switch and chmod +x&lt;/span&gt;

&lt;span class="nv"&gt;ACCOUNT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;1&lt;/span&gt;:?Usage:&lt;span class="p"&gt; claude-switch &amp;lt;account-name&amp;gt; [claude args...]&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;shift

&lt;/span&gt;&lt;span class="nv"&gt;CONFIG_DIR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/.claude-&lt;/span&gt;&lt;span class="nv"&gt;$ACCOUNT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CONFIG_DIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Account '&lt;/span&gt;&lt;span class="nv"&gt;$ACCOUNT&lt;/span&gt;&lt;span class="s2"&gt;' not found. Available accounts:"&lt;/span&gt;
  &lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; ~/.claude-&lt;span class="k"&gt;*&lt;/span&gt; 2&amp;gt;/dev/null | &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="s1"&gt;'s|.*/.claude-||'&lt;/span&gt;
  &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi

&lt;/span&gt;&lt;span class="nv"&gt;CLAUDE_CONFIG_DIR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CONFIG_DIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nb"&gt;exec &lt;/span&gt;claude &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$@&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Usage:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;claude-switch work
claude-switch personal &lt;span class="nt"&gt;--resume&lt;/span&gt;
claude-switch work &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"fix the login bug"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This passes all arguments through, so flags like &lt;code&gt;--resume&lt;/code&gt;, &lt;code&gt;--print&lt;/code&gt;, and &lt;code&gt;-p&lt;/code&gt; all work.&lt;/p&gt;




&lt;h2&gt;
  
  
  Per-project account defaults
&lt;/h2&gt;

&lt;p&gt;If a specific repo should always use a specific account, you can set it in a &lt;code&gt;.envrc&lt;/code&gt; file (if you use &lt;a href="https://direnv.net" rel="noopener noreferrer"&gt;direnv&lt;/a&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# /path/to/work-project/.envrc&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;CLAUDE_CONFIG_DIR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/.claude-work"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now every time you &lt;code&gt;cd&lt;/code&gt; into that project and run &lt;code&gt;claude&lt;/code&gt;, it automatically uses the work account. No alias needed.&lt;/p&gt;

&lt;p&gt;Without direnv, you can add it to the project's shell history or a local &lt;code&gt;.env&lt;/code&gt; file that your shell sources.&lt;/p&gt;




&lt;h2&gt;
  
  
  API key accounts vs OAuth accounts
&lt;/h2&gt;

&lt;p&gt;There are two authentication modes in Claude Code, and they interact differently with multi-account setups:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;OAuth (default):&lt;/strong&gt; You run &lt;code&gt;/login&lt;/code&gt; and authenticate through Anthropic's web flow. The token is stored in the config directory. This is what most people use with Claude Pro/Max subscriptions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;API key:&lt;/strong&gt; You set &lt;code&gt;ANTHROPIC_API_KEY&lt;/code&gt; as an environment variable. This bypasses the config directory's auth entirely.&lt;/p&gt;

&lt;p&gt;For API key setups, you don't even need separate config directories for auth. You can just switch the key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;alias &lt;/span&gt;claude-client-a&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'ANTHROPIC_API_KEY=$CLIENT_A_KEY claude'&lt;/span&gt;
&lt;span class="nb"&gt;alias &lt;/span&gt;claude-client-b&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'ANTHROPIC_API_KEY=$CLIENT_B_KEY claude'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But you'll still want separate config directories if you need isolated memory, hooks, or MCP servers.&lt;/p&gt;




&lt;h2&gt;
  
  
  Combining both: API key + config isolation
&lt;/h2&gt;

&lt;p&gt;The most robust setup for freelancers or consultants:&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;# ~/.zshrc&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;CLIENT_A_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"sk-ant-..."&lt;/span&gt;  &lt;span class="c"&gt;# or source from a secrets manager&lt;/span&gt;

&lt;span class="nb"&gt;alias &lt;/span&gt;claude-client-a&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'ANTHROPIC_API_KEY=$CLIENT_A_KEY CLAUDE_CONFIG_DIR=~/.claude-client-a claude'&lt;/span&gt;
&lt;span class="nb"&gt;alias &lt;/span&gt;claude-client-b&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'ANTHROPIC_API_KEY=$CLIENT_B_KEY CLAUDE_CONFIG_DIR=~/.claude-client-b claude'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each client gets:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Their own API key (billing goes to the right place)&lt;/li&gt;
&lt;li&gt;Their own memory (client context stays separate)&lt;/li&gt;
&lt;li&gt;Their own MCP servers (different clients, different tools)&lt;/li&gt;
&lt;li&gt;Their own hooks (different code review standards, different workflows)&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Running accounts simultaneously
&lt;/h2&gt;

&lt;p&gt;You can run multiple Claude Code instances in parallel, each in its own terminal tab. The config directories are independent, so there's no locking or conflict.&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;# Terminal 1&lt;/span&gt;
claude-work

&lt;span class="c"&gt;# Terminal 2&lt;/span&gt;
claude-personal
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both sessions run concurrently without interference. This is useful when you're waiting on a long task in one account and want to work on something else in another.&lt;/p&gt;




&lt;h2&gt;
  
  
  Quick reference
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What you want&lt;/th&gt;
&lt;th&gt;What to set&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Different auth&lt;/td&gt;
&lt;td&gt;&lt;code&gt;CLAUDE_CONFIG_DIR&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Different API key&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ANTHROPIC_API_KEY&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Different memory&lt;/td&gt;
&lt;td&gt;&lt;code&gt;CLAUDE_CONFIG_DIR&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Different MCP servers&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;CLAUDE_CONFIG_DIR&lt;/code&gt; + custom &lt;code&gt;settings.json&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Per-project default&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;.envrc&lt;/code&gt; with &lt;code&gt;CLAUDE_CONFIG_DIR&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;All of the above&lt;/td&gt;
&lt;td&gt;Combined alias with both env vars&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Going further: multiple Claude Desktop instances
&lt;/h2&gt;

&lt;p&gt;Everything above covers Claude Code (the CLI). If you also use the &lt;strong&gt;Claude desktop app&lt;/strong&gt; and want two instances running side by side with different accounts, the approach is different: you need to duplicate the app itself.&lt;/p&gt;

&lt;p&gt;On macOS, &lt;a href="https://www.parallels.com/products/toolbox/" rel="noopener noreferrer"&gt;Parallels Toolbox&lt;/a&gt; can create an "app shortcut" that acts as a second copy of Claude. Each copy maintains its own login session, so you can run your work account in one window and your personal account in another, without logging in and out. &lt;a href="https://youtu.be/IF1qZmCLWFk" rel="noopener noreferrer"&gt;This walkthrough&lt;/a&gt; shows the full setup.&lt;/p&gt;

&lt;p&gt;The process: open Parallels Toolbox, create an app shortcut pointing to Claude, give it a distinct name (like "Claude Work"), approve it in macOS security settings, and log in with your second account. Both instances live in your dock and run independently.&lt;/p&gt;

&lt;p&gt;This pairs well with the CLI multi-account setup: use &lt;code&gt;CLAUDE_CONFIG_DIR&lt;/code&gt; aliases for terminal work, and Parallels app shortcuts for the desktop GUI.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I actually use
&lt;/h2&gt;

&lt;p&gt;Two config directories: &lt;code&gt;~/.claude-personal&lt;/code&gt; for my own projects, &lt;code&gt;~/.claude-work&lt;/code&gt; for client work. Direnv handles the switching per project, so I just type &lt;code&gt;claude&lt;/code&gt; and it picks the right account. I symlink &lt;code&gt;settings.json&lt;/code&gt; from my personal config to the work one because I want the same hooks everywhere, but memory stays separate.&lt;/p&gt;

&lt;p&gt;The total setup took five minutes. The part that took longest was realizing I needed to re-run &lt;code&gt;/login&lt;/code&gt; in each config directory after creating it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://jguillaumesio.com/blog/claude-code-multiple-accounts/" rel="noopener noreferrer"&gt;jguillaumesio.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>claude</category>
      <category>ai</category>
      <category>productivity</category>
      <category>devtools</category>
    </item>
    <item>
      <title>Cloudflare under the hood: how it works and how attackers try to get around it</title>
      <dc:creator>jguillaumesio</dc:creator>
      <pubDate>Mon, 22 Jun 2026 10:21:35 +0000</pubDate>
      <link>https://dev.to/jguillaumesio/cloudflare-under-the-hood-how-it-works-and-how-attackers-try-to-get-around-it-3740</link>
      <guid>https://dev.to/jguillaumesio/cloudflare-under-the-hood-how-it-works-and-how-attackers-try-to-get-around-it-3740</guid>
      <description>&lt;h2&gt;
  
  
  1. What Cloudflare actually is
&lt;/h2&gt;

&lt;p&gt;Cloudflare is not a reverse proxy running on one server somewhere. It is a globally distributed edge network with over 300 points of presence (PoPs). When you put your domain behind Cloudflare, you are routing all traffic through that network before it ever reaches your server&lt;/p&gt;

&lt;p&gt;The mechanism is anycast routing. Cloudflare announces the same IP address from every PoP simultaneously. When a user sends a request to your site, BGP routing automatically directs it to the closest PoP, not to your origin server. From there, Cloudflare decides what to do with it&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User in Tokyo
   |
   | (anycast routes to nearest PoP)
   v
Cloudflare Tokyo PoP
   |-- cached? → serve from edge, origin never touched
   |-- blocked? → return 403, origin never touched
   |-- challenge? → run Turnstile, origin never touched
   |-- clean? → forward to origin, return response
   v
Your origin server
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;TLS termination happens at the edge PoP, not at your origin. Cloudflare holds the certificate, decrypts the request, inspects it, then re-encrypts it for the leg to your origin (assuming SSL between Cloudflare and origin is enabled, which it should be)&lt;/p&gt;

&lt;p&gt;This is why Cloudflare can inspect HTTPS traffic for WAF rules without a man-in-the-middle attack: you are explicitly delegating that decryption to them&lt;/p&gt;




&lt;h2&gt;
  
  
  2. The layers between a request and your server
&lt;/h2&gt;

&lt;p&gt;A request arriving at a Cloudflare PoP passes through several decision layers in order:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;DDoS mitigation&lt;/strong&gt; runs first. Volumetric floods are absorbed at the network layer. HTTP floods are identified by rate, pattern, and reputation&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;IP reputation and geofencing&lt;/strong&gt; checks the source IP against Cloudflare's threat database. IPs from known botnets, Tor exit nodes, or datacenter ranges are scored&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;WAF&lt;/strong&gt; inspects the HTTP layer: headers, path, query params, body. Cloudflare maintains a managed ruleset covering OWASP Top 10 plus known CVEs&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bot management&lt;/strong&gt; (Turnstile is the visible part) assigns each request a bot score from 1 to 99. Score 1 is almost certainly a bot. Score 99 is almost certainly human&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cache&lt;/strong&gt; is the last layer before origin. If the response is cacheable and a fresh copy exists at the PoP, Cloudflare serves it without touching your server&lt;/p&gt;




&lt;h2&gt;
  
  
  3. How Turnstile works
&lt;/h2&gt;

&lt;p&gt;Turnstile is Cloudflare's CAPTCHA replacement. Unlike reCAPTCHA v2, it has no image challenge: the goal is to verify a visitor is human without making them solve anything visible&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. The widget loads a JS challenge from Cloudflare's edge.&lt;/strong&gt; The script is different per request, not a static file you can analyze once&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The script collects passive signals:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Timing: how long did each JS operation take? Headless browsers running at full CPU speed have suspiciously uniform timing.&lt;/li&gt;
&lt;li&gt;Interaction: did the mouse move before the form was submitted? Did keystrokes have natural delays?&lt;/li&gt;
&lt;li&gt;Browser fingerprint: canvas rendering, WebGL renderer, installed fonts, audio context output.&lt;/li&gt;
&lt;li&gt;Environment: is &lt;code&gt;navigator.webdriver&lt;/code&gt; exposed? Are dev tools open?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;3. Cloudflare runs those signals through a model&lt;/strong&gt; trained on billions of requests and issues a signed token if the request looks human&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Your backend verifies the token&lt;/strong&gt; against Cloudflare's siteverify API:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;POST&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;https://challenges.cloudflare.com/turnstile/v&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="err"&gt;/siteverify&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"secret"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"your-secret-key"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"response"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"token-from-widget"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your backend does not make this call, the protection is entirely client-side and trivially bypassed by skipping the form submission step&lt;/p&gt;




&lt;h2&gt;
  
  
  4. Finding the origin server behind Cloudflare
&lt;/h2&gt;

&lt;p&gt;If an attacker finds your origin IP, they can bypass Cloudflare entirely by sending requests directly to that IP. Your WAF, DDoS protection, and Turnstile all disappear&lt;/p&gt;

&lt;p&gt;Here are the techniques commonly used, in order of how often they succeed&lt;/p&gt;

&lt;h3&gt;
  
  
  SSL certificate history
&lt;/h3&gt;

&lt;p&gt;Before you put a domain behind Cloudflare, it had a certificate issued directly to the origin. Certificate transparency logs are public and record every certificate ever issued:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://crt.sh/?q=example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the origin IP appeared in a certificate before Cloudflare was enabled, it is in the log forever&lt;/p&gt;

&lt;h3&gt;
  
  
  DNS history
&lt;/h3&gt;

&lt;p&gt;Before Cloudflare, your A record pointed directly to your origin. Those records are archived by SecurityTrails, DNSDumpster, and ViewDNS.info, often with timestamps showing exactly when you switched&lt;/p&gt;

&lt;h3&gt;
  
  
  Subdomains not behind Cloudflare
&lt;/h3&gt;

&lt;p&gt;Many teams proxy &lt;code&gt;www&lt;/code&gt; and the apex but leave other subdomains with a grey cloud (not proxied) by accident:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;ftp.example.com&lt;/code&gt;: legacy, often points to origin&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;dev.example.com&lt;/code&gt;, &lt;code&gt;staging.example.com&lt;/code&gt;: forgotten&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;api.example.com&lt;/code&gt;: sometimes bypasses the proxy for latency reasons&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A subdomain enumeration pass reveals which subdomains resolve to a non-Cloudflare IP&lt;/p&gt;

&lt;h3&gt;
  
  
  MX records
&lt;/h3&gt;

&lt;p&gt;Mail servers cannot be proxied through Cloudflare. Your MX record points directly to a mail server, often on the same IP block as your web server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dig MX example.com        &lt;span class="c"&gt;# → mail.example.com&lt;/span&gt;
dig A mail.example.com    &lt;span class="c"&gt;# → 203.0.113.42&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  SPF records
&lt;/h3&gt;

&lt;p&gt;SPF records list every IP authorized to send email on your behalf. They often include your origin server or hosting provider's IP range:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dig TXT example.com
&lt;span class="c"&gt;# v=spf1 ip4:203.0.113.0/24 include:sendgrid.net ~all&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Shodan + certificate fingerprint
&lt;/h3&gt;

&lt;p&gt;If your origin uses a Cloudflare origin certificate, its fingerprint is the same regardless of how it is accessed. Shodan and Censys index TLS certificates across the entire IPv4 space: search for your cert fingerprint to find the raw IP&lt;/p&gt;




&lt;h2&gt;
  
  
  5. Bypassing Turnstile
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Solving services
&lt;/h3&gt;

&lt;p&gt;2captcha, Anti-Captcha, and CapSolver use human workers who run a real browser session and return the token. This works but is slow (seconds per token) and costs money per solve. Practical at low volume, expensive at scale&lt;/p&gt;

&lt;h3&gt;
  
  
  Headless browser spoofing
&lt;/h3&gt;

&lt;p&gt;Playwright and Puppeteer combined with stealth plugins patch the detectable properties:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;navigator.webdriver&lt;/code&gt; set to &lt;code&gt;undefined&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Spoofed canvas fingerprint&lt;/li&gt;
&lt;li&gt;Realistic mouse movement and keystroke timing&lt;/li&gt;
&lt;li&gt;Full Chrome user agent&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A well-configured headless browser can pass Turnstile at a reasonable rate. Cloudflare's model is continuously updated, but it is an ongoing arms race&lt;/p&gt;

&lt;h3&gt;
  
  
  What actually stops most bots
&lt;/h3&gt;

&lt;p&gt;The visible Turnstile widget is not the main defense. Cloudflare's bot score from network-level signals (IP reputation, ASN, request rate, TLS fingerprint) catches far more traffic than the JS challenge does. A request from AWS Lambda with a clean User-Agent still has a datacenter ASN: that alone raises the bot score before any JS runs&lt;/p&gt;

&lt;p&gt;Turnstile alone, validated client-side only, is weak. The combination of network scoring plus behavioral analysis is what makes the system effective&lt;/p&gt;




&lt;h2&gt;
  
  
  6. How to actually protect your origin
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Use Cloudflare Tunnel.&lt;/strong&gt; This is the only approach that fully hides your origin IP. &lt;code&gt;cloudflared&lt;/code&gt; opens an outbound connection from your server to Cloudflare's network. No open inbound ports, no IP to find.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;cloudflared tunnel create my-tunnel
cloudflared tunnel route dns my-tunnel example.com
cloudflared tunnel run my-tunnel
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;If you cannot use Tunnel, firewall your origin to Cloudflare IPs only.&lt;/strong&gt; Cloudflare publishes its full IP range at &lt;code&gt;cloudflare.com/ips-v4&lt;/code&gt;. Allow only those ranges on 80 and 443. Drop everything else&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Proxy every subdomain.&lt;/strong&gt; Audit your DNS records. Every subdomain that should be proxied must have the orange cloud enabled. Grey-cloud records pointing to your origin are a bypass by design&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Keep mail on a separate IP.&lt;/strong&gt; Your mail server should not share an IP or IP block with your web server&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Validate Turnstile server-side, always.&lt;/strong&gt; The token must be verified by your backend on every form submission&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check your certificate history now.&lt;/strong&gt; Run your domain through &lt;code&gt;crt.sh&lt;/code&gt; and SecurityTrails. If your old origin IP is visible, either move to a new IP (and use Tunnel going forward) or rely entirely on the firewall approach&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://jguillaumesio.com/blog/cloudflare-under-the-hood/" rel="noopener noreferrer"&gt;jguillaumesio.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>infrastructure</category>
      <category>bypass</category>
      <category>cloudflare</category>
    </item>
  </channel>
</rss>
