<?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>Forem: Juan Torchia</title>
    <description>The latest articles on Forem by Juan Torchia (@jtorchia).</description>
    <link>https://forem.com/jtorchia</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F885942%2F5b3b3860-d364-4de0-a335-cb7c251109d9.jpeg</url>
      <title>Forem: Juan Torchia</title>
      <link>https://forem.com/jtorchia</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/jtorchia"/>
    <language>en</language>
    <item>
      <title>Mercor's 4TB Voice Heist: I Ran the Same Attack on My Own AI Data Stack</title>
      <dc:creator>Juan Torchia</dc:creator>
      <pubDate>Tue, 28 Apr 2026 16:33:09 +0000</pubDate>
      <link>https://forem.com/jtorchia/mercors-4tb-voice-heist-i-ran-the-same-attack-on-my-own-ai-data-stack-hk</link>
      <guid>https://forem.com/jtorchia/mercors-4tb-voice-heist-i-ran-the-same-attack-on-my-own-ai-data-stack-hk</guid>
      <description>&lt;h1&gt;
  
  
  Mercor's 4TB Voice Heist: I Ran the Same Attack on My Own AI Data Stack
&lt;/h1&gt;

&lt;p&gt;80% of breaches on training data platforms involve third-party credentials — not direct attacks on the core company. Yeah, you read that right. They don't storm the castle. They steal the key from the contractor who walks in and out every day. When Mercor confirmed they lost 4TB of voice samples from roughly 40,000 AI contractors, my first thought wasn't "that's rough for them." My first thought was: &lt;em&gt;I have the exact same pattern in my own infra&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;I'm not Mercor. I don't have 40k workers or petabytes of audio. But I do have data pipelines, I have API tokens that rotate poorly, I have training artifacts living in buckets with wider permissions than they should have. And I have a history with this kind of simulation: when &lt;a href="https://juanchi.dev/en/blog/godaddy-domain-hijacking-simulated-attack-own-infra" rel="noopener noreferrer"&gt;GoDaddy transferred my domain to a stranger&lt;/a&gt;, I didn't write an opinion thread — I mapped the same attack surface against my own infra to understand exactly how exposed I was. I did the same thing here.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Mercor Pattern: Not a Bug, It's Architecture
&lt;/h2&gt;

&lt;p&gt;What happened at Mercor isn't some exotic exploit. It's the most boring and most dangerous pattern in today's AI ecosystem: &lt;strong&gt;sensitive data delegated to contractors, with insufficient granular access and zero credential rotation&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Labeling and voice recording contractors on platforms like Mercor work with tools that need access to storage buckets, upload endpoints, and sometimes SDKs with long-lived tokens. That's not speculation — it's the standard operating model. The problem is those tokens travel in environment variables on personal laptops, in &lt;code&gt;.env&lt;/code&gt; files that sometimes end up in "private" repos (not so private), and in mobile app configs that outlive the freelance project by months.&lt;/p&gt;

&lt;p&gt;4TB of audio doesn't get exfiltrated through a sophisticated attack. It gets copied with a valid token and an &lt;code&gt;aws s3 sync&lt;/code&gt; or equivalent. Probably something like 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;# The most boring attack in the world&lt;/span&gt;
&lt;span class="c"&gt;# A leaked token + broad read access = silent catastrophe&lt;/span&gt;

aws s3 &lt;span class="nb"&gt;sync &lt;/span&gt;s3://mercor-voice-samples-prod ./dump &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--region&lt;/span&gt; us-east-1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--profile&lt;/span&gt; compromised_contractor
  &lt;span class="c"&gt;# no rate limiting, no alerts, no MFA on the profile&lt;/span&gt;
  &lt;span class="c"&gt;# 4TB at ~100MB/s = ~11 hours of quiet syncing&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No CVE required. Just a token that never expired.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I Found When I Simulated the Attack on My Own Stack
&lt;/h2&gt;

&lt;p&gt;Here's the uncomfortable part. After reading the Mercor report, I opened my own console and started auditing. My current stack: Next.js on Railway, PostgreSQL, some text processing pipelines for autocomplete features, and access to model APIs (OpenAI, Anthropic). I don't record voices. But I do accumulate data that, in the wrong hands, is a real problem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;First pass: active tokens with broad access&lt;/strong&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;# Basic audit: how many active tokens do I have that I shouldn't?&lt;/span&gt;
&lt;span class="c"&gt;# Ran this against my API key list in Railway + .env files from old projects&lt;/span&gt;

&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s2"&gt;"API_KEY&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;SECRET&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;TOKEN"&lt;/span&gt; ~/.env_&lt;span class="k"&gt;*&lt;/span&gt; ./projects/&lt;span class="k"&gt;**&lt;/span&gt;/.env 2&amp;gt;/dev/null &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="s2"&gt;".env.example"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;

&lt;span class="c"&gt;# Result: 23&lt;/span&gt;
&lt;span class="c"&gt;# Active tokens I should have rotated months ago: 23&lt;/span&gt;
&lt;span class="c"&gt;# Tokens with a configured expiration date: 4&lt;/span&gt;
&lt;span class="c"&gt;# Ratio that made me feel bad: 82.6%&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Twenty-three tokens. Four with expiration. The rest, eternal by default.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Second pass: metadata surface in Railway logs&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When &lt;a href="https://juanchi.dev/en/blog/ai-agent-deleted-production-database-logs-guardrails-real-analysis" rel="noopener noreferrer"&gt;the agent deleted my production database&lt;/a&gt; last year, I learned to look at logs with a whole new level of paranoia. But this time I was hunting for something different: what API usage metadata am I logging without realizing it?&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// What I found in my Railway logs — sanitized but real&lt;/span&gt;
&lt;span class="c1"&gt;// The problem: I was logging the full request for debugging, including headers&lt;/span&gt;

&lt;span class="nx"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;API request&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;method&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="c1"&gt;// ← PROBLEM: includes Authorization header&lt;/span&gt;
  &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;  &lt;span class="c1"&gt;// ← PROBLEM: includes users' full prompts&lt;/span&gt;
  &lt;span class="na"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Result: logs with visible Bearer tokens, real user prompts,&lt;/span&gt;
&lt;span class="c1"&gt;// and enough userId+behavior correlation to reconstruct profiles&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Not voice. But user behavior + tokens in plain text in persistent logs. Same vector, different format.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Third pass: training artifacts in buckets&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I have a bucket in Railway Volumes with fine-tuning datasets I used for experiments. I ran a permissions audit:&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;# Check access policy on Railway Volumes (functional equivalent)&lt;/span&gt;
&lt;span class="c"&gt;# If you use S3 directly, swap with aws s3api get-bucket-acl&lt;/span&gt;

railway volume list &lt;span class="nt"&gt;--json&lt;/span&gt; | jq &lt;span class="s1"&gt;'.[].accessPolicy'&lt;/span&gt;
&lt;span class="c"&gt;# Output I didn't want to see:&lt;/span&gt;
&lt;span class="c"&gt;# "accessPolicy": "project-wide"&lt;/span&gt;
&lt;span class="c"&gt;# Means: any service in the project can read/write&lt;/span&gt;
&lt;span class="c"&gt;# Including the preview deployments service&lt;/span&gt;
&lt;span class="c"&gt;# Including open PR branches&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;PR preview deployments have read access to my training data volumes. That means any external collaborator who opens a PR — or any attacker who compromises that surface — can reach those artifacts.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Mistakes Mercor Didn't Invent: It Inherited Them from the Ecosystem
&lt;/h2&gt;

&lt;p&gt;My thesis after this simulation: &lt;strong&gt;Mercor didn't do anything weird. It did what 90% of the AI data platform ecosystem does&lt;/strong&gt;. And that's exactly the problem.&lt;/p&gt;

&lt;p&gt;The distributed contractor model for data labeling and recording was born from the need to scale fast. RLHF, voice recording, response evaluation — all of this requires globally distributed human work. The access infrastructure was designed to facilitate that work, not to resist an adversary who steals a token from a contractor in Manila or Lagos.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gotcha #1: long-lived tokens as the default&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Most AI task platforms issue tokens with 30-90 day expirations. On a two-week contract, the token outlives the work by months. Nobody does credential offboarding because nobody has the process.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gotcha #2: broad read access for "operational convenience"&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If a contractor needs to download reference samples to calibrate their work, the easiest solution is to give them read access to the entire bucket. Scoping access by batch, by date, or by task ID requires additional engineering that doesn't always get prioritized.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gotcha #3: no alerts on anomalous access patterns&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A legitimate contractor accesses 200-300 files per work session. A 4TB sync is 40 million small files or thousands of large files in a short window. That should trigger an alert. If it didn't, there was no baseline of normal behavior configured.&lt;/p&gt;

&lt;p&gt;This connects to something that &lt;a href="https://juanchi.dev/en/blog/typescript-7-beta-real-codebase-results-what-changed" rel="noopener noreferrer"&gt;TypeScript 7.0 made me revisit in my codebase&lt;/a&gt;: most of the security issues I found weren't logic bugs — they were the absence of constraints. Without strict types, without strict access policies, the system does what it can, not what it should.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I Changed in My Stack After the Simulation
&lt;/h2&gt;

&lt;p&gt;I'm not Mercor, but the exercise left me with a concrete list. I'm sharing it because these changes are replicable on any small stack:&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;# 1. Forced rotation of tokens with no expiration date&lt;/span&gt;
&lt;span class="c"&gt;# Script I ran to identify and revoke them&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;key &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s2"&gt;"sk-"&lt;/span&gt; ~/.env_&lt;span class="k"&gt;*&lt;/span&gt; | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="nt"&gt;-F&lt;/span&gt;&lt;span class="s1"&gt;'='&lt;/span&gt; &lt;span class="s1"&gt;'{print $2}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="c"&gt;# Check last-used time via Railway logs&lt;/span&gt;
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Reviewing: &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;key&lt;/span&gt;:0:8&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;..."&lt;/span&gt;
  &lt;span class="c"&gt;# Revoke if last use &amp;gt; 30 days&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;

&lt;span class="c"&gt;# Result: revoked 14 tokens, 9 of which hadn't been used in 60+ days&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// 2. Log sanitization — what should have been there from day one&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sanitizeForLog&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;SENSITIVE_FIELDS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;authorization&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;token&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;secret&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;password&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;body&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromEntries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(([&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;SENSITIVE_FIELDS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;field&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;field&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[REDACTED]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
        &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;value&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="c1"&gt;// Usage:&lt;/span&gt;
&lt;span class="nx"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;API request&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;sanitizeForLog&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;method&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// now → '[REDACTED]' for Authorization&lt;/span&gt;
  &lt;span class="na"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;
&lt;span class="p"&gt;}));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 3. Volume isolation per service in Railway&lt;/span&gt;
&lt;span class="c"&gt;# Changed from "project-wide" to "service-specific"&lt;/span&gt;

railway volume update &lt;span class="nt"&gt;--service&lt;/span&gt; api-production &lt;span class="nt"&gt;--access&lt;/span&gt; service-only
&lt;span class="c"&gt;# Preview deployments no longer have access&lt;/span&gt;
&lt;span class="c"&gt;# Operational cost: had to configure an authenticated download endpoint&lt;/span&gt;
&lt;span class="c"&gt;# Worth it&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The third one was the most painful. I had preview deployments using the same data as production to "make testing easier." It was convenient. It was also a direct attack surface. When &lt;a href="https://juanchi.dev/en/blog/plain-text-won-migrating-notion-to-markdown-what-i-lost" rel="noopener noreferrer"&gt;I migrated my notes from Notion to Markdown&lt;/a&gt; I learned that convenience has hidden costs. Same thing here: the convenience of "shared access" carries an attack surface cost I wasn't measuring.&lt;/p&gt;




&lt;h2&gt;
  
  
  FAQ: Data Security in AI Contractor Stacks
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What exactly was stolen from Mercor and why does it matter?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Mercor is a platform that connects AI companies with contractors for labeling, evaluation, and data recording tasks. The 4TB that were stolen are voice samples collected from approximately 40,000 workers. The severity is twofold: first, voice recordings are biometric data — they're irrevocable. You can't change someone's voice like you change a password. Second, those samples include metadata (name, location, device) that allows building complete profiles of people who generally work in vulnerable economies.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How can this affect someone who doesn't use Mercor but works with training data?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The vector is the same regardless of the platform. If you store training datasets in buckets with broad access, if you issue long-lived tokens to external collaborators, or if you log user metadata without sanitizing it, you have the same surface. The name of the compromised company changes; the risk architecture is identical.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What's the difference between this theft and a regular credential breach?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The main difference is the irreversible nature of the data. When someone steals your password, you change it. When someone steals a voice sample trained on thousands of hours of recordings, there's no revocation possible. On top of that, AI training data has very specific market value: it's used to train voice cloning models, forged biometric authentication systems, and to evade deepfake detection systems. That market exists and pays well.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is rotating tokens regularly enough to stay protected?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No, but it's the first rung. Token rotation attacks the long-lived credential problem, but it doesn't solve broad access, logs with sensitive information, or the absence of anomalous behavior alerts. You also need: least privilege access policies, alerts on download patterns outside of baseline, and strict separation between development and production environments.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What API usage data from LLMs am I unknowingly exposing?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;More than you think. Typically: full user prompts in debugging logs, authentication tokens in logged headers, behavior patterns that allow user identification even if you don't explicitly store PII, and intermediate processing artifacts that may include fragments of training data. I ran the audit described in this post and found 23 active tokens without expiration and logs with Authorization headers in plain text. It's not unusual — it's the default if you don't actively configure otherwise.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Should I worry if I'm an indie developer with no voice data?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes, but with proportion. You don't have the same risk as Mercor. But if you use LLM APIs, you have tokens. If you have tokens, you have credentials that can be compromised. If you log requests for debugging — and almost all of us do — you have potential user data exposure. The scale changes, the pattern doesn't. The minimum useful exercise: audit how many active tokens you have today, how many have an expiration date, and what you're logging in production.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Uncomfortable Part I'm Not Going to Soften
&lt;/h2&gt;

&lt;p&gt;When I finished the simulation, I had found 23 tokens without expiration, logs with authentication headers in plain text, and preview deployments with access to production training data. I didn't suffer a breach. But if someone had compromised one of those tokens before I rotated them, the damage would have been real and silent.&lt;/p&gt;

&lt;p&gt;What I take away from Mercor isn't moral outrage — though 4TB of voice from 40k contractors is concrete harm to real people. What I take away is that &lt;strong&gt;the AI training data ecosystem built its access infrastructure for speed, not resilience&lt;/strong&gt;. And when that model scales to millions of globally distributed contractors, the attack surface grows faster than the controls.&lt;/p&gt;

&lt;p&gt;My position is this: if you're building AI data pipelines — even at indie scale — auditing credentials and permissions is not a "when I have time" task. It's technical debt that, if you don't pay it, someone else collects it for you.&lt;/p&gt;

&lt;p&gt;The same pattern that &lt;a href="https://juanchi.dev/en/blog/godaddy-domain-hijacking-simulated-attack-own-infra" rel="noopener noreferrer"&gt;the GoDaddy domain attack taught me&lt;/a&gt; applies here: the breach doesn't happen where you're paying attention. It happens in the token you forgot, the log you never reviewed, the bucket you left wide open "just in case."&lt;/p&gt;

&lt;p&gt;Go count your active tokens. I counted 23. How many do you have?&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This article was originally published on &lt;a href="https://juanchi.dev/en/blog/mercor-4tb-voice-breach-simulated-attack-ai-data-stack" rel="noopener noreferrer"&gt;juanchi.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>english</category>
      <category>railway</category>
      <category>privacidad</category>
      <category>mercorbreach</category>
    </item>
    <item>
      <title>4TB de voz robados de Mercor: simulé el mismo ataque sobre mi stack de datos IA</title>
      <dc:creator>Juan Torchia</dc:creator>
      <pubDate>Tue, 28 Apr 2026 16:33:04 +0000</pubDate>
      <link>https://forem.com/jtorchia/4tb-de-voz-robados-de-mercor-simule-el-mismo-ataque-sobre-mi-stack-de-datos-ia-1eb9</link>
      <guid>https://forem.com/jtorchia/4tb-de-voz-robados-de-mercor-simule-el-mismo-ataque-sobre-mi-stack-de-datos-ia-1eb9</guid>
      <description>&lt;h1&gt;
  
  
  4TB de voz robados de Mercor: simulé el mismo ataque sobre mi stack de datos IA
&lt;/h1&gt;

&lt;p&gt;El 80% de los breaches en plataformas de datos de entrenamiento involucran credenciales de terceros, no ataques directos a la empresa central. Sí, leíste bien. No rompen el castillo — roban la llave al contratista que entra y sale todos los días. Y cuando Mercor confirmó que perdió 4TB de muestras de voz de aproximadamente 40.000 contratistas IA, lo primero que pensé no fue "qué mal por ellos". Lo primero que pensé fue: &lt;em&gt;yo tengo el mismo patrón en mi infra&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;No soy Mercor. No tengo 40k workers ni petabytes de audio. Pero tengo pipelines de datos, tengo tokens de API que rotan mal, tengo artefactos de entrenamiento que viven en buckets con permisos más anchos de lo que debería. Y tengo una historia con este tipo de simulaciones: cuando &lt;a href="https://juanchi.dev/es/blog/godaddy-domain-hijacking-security-simulacion-ataque-infra-propia" rel="noopener noreferrer"&gt;GoDaddy me transfirió mi dominio a un desconocido&lt;/a&gt;, no escribí un thread de opinión — monté la misma superficie de ataque sobre mi propia infra para entender qué tan expuesto estaba. Hice lo mismo acá.&lt;/p&gt;




&lt;h2&gt;
  
  
  El patrón Mercor: no es un bug, es arquitectura
&lt;/h2&gt;

&lt;p&gt;Lo que pasó en Mercor no es un exploit exótico. Es el patrón más aburrido y más peligroso del ecosistema IA actual: &lt;strong&gt;datos sensibles delegados a contratistas, con acceso granular insuficiente y rotación de credenciales inexistente&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Los contratistas de etiquetado y grabación de voz en plataformas como Mercor trabajan con herramientas que requieren acceso a buckets de almacenamiento, endpoints de upload, y a veces SDKs con tokens de larga duración. No es hipótesis — es el modelo operativo estándar. El problema es que esos tokens viajan en variables de entorno en laptops personales, en &lt;code&gt;.env&lt;/code&gt; files que a veces terminan en repos privados (pero no tanto), y en configuraciones de apps móviles que tienen la vida útil de un proyecto freelance.&lt;/p&gt;

&lt;p&gt;4TB de audio no se copian en un ataque sofisticado. Se copian con un token válido y un &lt;code&gt;aws s3 sync&lt;/code&gt; o equivalente. Probablemente así:&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;# El ataque más aburrido del mundo&lt;/span&gt;
&lt;span class="c"&gt;# Un token filtrado + acceso de lectura amplio = catástrofe silenciosa&lt;/span&gt;

aws s3 &lt;span class="nb"&gt;sync &lt;/span&gt;s3://mercor-voice-samples-prod ./dump &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--region&lt;/span&gt; us-east-1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--profile&lt;/span&gt; contratista_comprometido
  &lt;span class="c"&gt;# sin rate limiting, sin alertas, sin MFA en el perfil&lt;/span&gt;
  &lt;span class="c"&gt;# 4TB a ~100MB/s = ~11 horas de sync tranquilo&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Esto no requiere CVE. Requiere un token que no venció.&lt;/p&gt;




&lt;h2&gt;
  
  
  Lo que encontré cuando simulé el ataque sobre mi propio stack
&lt;/h2&gt;

&lt;p&gt;Acá viene la parte incómoda. Después de leer el informe de Mercor, abrí mi propia consola y empecé a auditar. Mi stack actual: Next.js en Railway, PostgreSQL, algunos pipelines de procesamiento de texto para features de autocompletado, y acceso a APIs de modelos (OpenAI, Anthropic). No grabo voces. Pero sí acumulo datos que, en manos equivocadas, son un problema.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Primera revisión: tokens activos con acceso amplio&lt;/strong&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;# Auditoría básica: ¿cuántos tokens tengo activos que no debería?&lt;/span&gt;
&lt;span class="c"&gt;# Corrí esto contra mi lista de API keys en Railway + .env de proyectos viejos&lt;/span&gt;

&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s2"&gt;"API_KEY&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;SECRET&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;TOKEN"&lt;/span&gt; ~/.env_&lt;span class="k"&gt;*&lt;/span&gt; ./projects/&lt;span class="k"&gt;**&lt;/span&gt;/.env 2&amp;gt;/dev/null &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="s2"&gt;".env.example"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;

&lt;span class="c"&gt;# Resultado: 23&lt;/span&gt;
&lt;span class="c"&gt;# Tokens activos que debería haber rotado hace meses: 23&lt;/span&gt;
&lt;span class="c"&gt;# Tokens con fecha de expiración configurada: 4&lt;/span&gt;
&lt;span class="c"&gt;# Proporción que me hizo sentir mal: 82.6%&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Veintitrés tokens. Cuatro con expiración. El resto, eternos por omisión.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Segunda revisión: superficie de metadatos en logs de Railway&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Cuando &lt;a href="https://juanchi.dev/es/blog/agente-ia-borro-base-datos-produccion-logs-guardrails" rel="noopener noreferrer"&gt;el agente me borró la base de datos&lt;/a&gt; el año pasado, aprendí a mirar los logs con otro nivel de paranoia. Pero esta vez busqué algo distinto: ¿qué metadatos de uso de API estoy logueando sin querer?&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Lo que encontré en mis logs de Railway — sanitizado pero real&lt;/span&gt;
&lt;span class="c1"&gt;// El problema: loggueaba el request completo para debugging, incluyendo headers&lt;/span&gt;

&lt;span class="nx"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;API request&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;method&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="c1"&gt;// ← PROBLEMA: incluye Authorization header&lt;/span&gt;
  &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;  &lt;span class="c1"&gt;// ← PROBLEMA: incluye prompts completos del usuario&lt;/span&gt;
  &lt;span class="na"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Resultado: logs con tokens Bearer visibles, prompts de usuarios reales,&lt;/span&gt;
&lt;span class="c1"&gt;// y suficiente correlación userId+comportamiento para reconstruir perfiles&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No era voz. Pero era comportamiento de usuario + tokens en texto plano en logs persistentes. Mismo vector, diferente formato.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tercera revisión: artefactos de entrenamiento en buckets&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Tengo un bucket en Railway Volumes con datasets de fine-tuning que usé para experimentos. Corrí una auditoría de permisos:&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;# Verificar política de acceso en Railway Volumes (equivalente funcional)&lt;/span&gt;
&lt;span class="c"&gt;# Si usás S3 directo, reemplazá con aws s3api get-bucket-acl&lt;/span&gt;

railway volume list &lt;span class="nt"&gt;--json&lt;/span&gt; | jq &lt;span class="s1"&gt;'.[].accessPolicy'&lt;/span&gt;
&lt;span class="c"&gt;# Output que no quería ver:&lt;/span&gt;
&lt;span class="c"&gt;# "accessPolicy": "project-wide"&lt;/span&gt;
&lt;span class="c"&gt;# Significa: cualquier servicio del proyecto puede leer/escribir&lt;/span&gt;
&lt;span class="c"&gt;# Incluyendo el servicio de preview deployments&lt;/span&gt;
&lt;span class="c"&gt;# Incluyendo branches de PRs abiertas&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Los preview deployments de PRs tienen acceso de lectura a mis volúmenes de datos de entrenamiento. Eso significa que cualquier colaborador externo que abra una PR — o cualquier atacante que comprometa esa superficie — puede llegar a esos artefactos.&lt;/p&gt;




&lt;h2&gt;
  
  
  Los errores que Mercor no inventó: los heredó del ecosistema
&lt;/h2&gt;

&lt;p&gt;Mi tesis, después de esta simulación: &lt;strong&gt;Mercor no hizo nada raro. Hizo lo que hace el 90% del ecosistema de plataformas de datos IA&lt;/strong&gt;. Y eso es exactamente el problema.&lt;/p&gt;

&lt;p&gt;El modelo de contratistas distribuidos para etiquetado y grabación de datos nació de la necesidad de escalar rápido. RLHF, grabación de voz, evaluación de respuestas — todo esto requiere trabajo humano distribuido globalmente. La infraestructura de acceso se diseñó para facilitar ese trabajo, no para resistir un adversario que robe un token de un contratista en Manila o Lagos.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gotcha #1: tokens de larga duración como default&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;La mayoría de las plataformas de tareas IA emiten tokens con expiración de 30-90 días. En un contrato que dura dos semanas, el token sobrevive al trabajo por meses. Nadie hace offboarding de credenciales porque nadie tiene el proceso.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gotcha #2: acceso de lectura amplio para "comodidad operativa"&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Si un contratista necesita descargar muestras de referencia para calibrar su trabajo, la solución más fácil es darle acceso de lectura al bucket completo. Scopear el acceso por lote, por fecha o por ID de tarea requiere ingeniería adicional que no siempre se prioriza.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gotcha #3: ausencia de alertas en patrones de acceso anómalos&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Un contratista legítimo accede a 200-300 archivos por sesión de trabajo. Un sync de 4TB es 40 millones de archivos pequeños o miles de archivos grandes en una ventana corta. Eso debería disparar una alerta. Si no la disparó, no había baseline de comportamiento normal configurado.&lt;/p&gt;

&lt;p&gt;Esto conecta con algo que &lt;a href="https://juanchi.dev/es/blog/typescript-70-beta-novedades-prueba-codebase-real" rel="noopener noreferrer"&gt;TypeScript 7.0 me hizo revisar en mi codebase&lt;/a&gt;: la mayoría de los problemas de seguridad que encontré no eran bugs de lógica — eran ausencia de constraints. Sin tipos estrictos, sin políticas de acceso estrictas, el sistema hace lo que puede, no lo que debe.&lt;/p&gt;




&lt;h2&gt;
  
  
  Lo que cambié en mi stack después de la simulación
&lt;/h2&gt;

&lt;p&gt;No soy Mercor, pero el ejercicio me dejó con una lista concreta. La comparto porque los cambios son replicables en cualquier stack pequeño:&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;# 1. Rotación forzada de tokens sin fecha de expiración&lt;/span&gt;
&lt;span class="c"&gt;# Script que corrí para identificar y revocar&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;key &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s2"&gt;"sk-"&lt;/span&gt; ~/.env_&lt;span class="k"&gt;*&lt;/span&gt; | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="nt"&gt;-F&lt;/span&gt;&lt;span class="s1"&gt;'='&lt;/span&gt; &lt;span class="s1"&gt;'{print $2}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="c"&gt;# Verificar última vez usado via logs de Railway&lt;/span&gt;
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Revisando: &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;key&lt;/span&gt;:0:8&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;..."&lt;/span&gt;
  &lt;span class="c"&gt;# Revocar si último uso &amp;gt; 30 días&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;

&lt;span class="c"&gt;# Resultado: revoqué 14 tokens, de los cuales 9 no habían sido usados en 60+ días&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// 2. Sanitización de logs — lo que debería haber estado desde el principio&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sanitizeForLog&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;CAMPOS_SENSIBLES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;authorization&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;token&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;secret&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;password&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;body&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromEntries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(([&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;CAMPOS_SENSIBLES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;campo&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;campo&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[REDACTADO]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
        &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;value&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="c1"&gt;// Uso:&lt;/span&gt;
&lt;span class="nx"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;API request&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;sanitizeForLog&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;method&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// ahora → '[REDACTADO]' para Authorization&lt;/span&gt;
  &lt;span class="na"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;
&lt;span class="p"&gt;}));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 3. Aislamiento de volúmenes por servicio en Railway&lt;/span&gt;
&lt;span class="c"&gt;# Cambié de "project-wide" a "service-specific"&lt;/span&gt;

railway volume update &lt;span class="nt"&gt;--service&lt;/span&gt; api-produccion &lt;span class="nt"&gt;--access&lt;/span&gt; service-only
&lt;span class="c"&gt;# Preview deployments ya no tienen acceso&lt;/span&gt;
&lt;span class="c"&gt;# Costo operativo: tuve que configurar un endpoint de descarga autenticado&lt;/span&gt;
&lt;span class="c"&gt;# Vale la pena&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El tercer punto fue el más doloroso. Tenía preview deployments que usaban los mismos datos que producción para "facilitar el testing". Era conveniente. Era también una superficie de ataque directa. Cuando &lt;a href="https://juanchi.dev/es/blog/migrar-notion-markdown-plain-text-lo-que-perdi" rel="noopener noreferrer"&gt;migré mis notas de Notion a Markdown&lt;/a&gt; aprendí que la comodidad tiene costos ocultos. Acá aplica igual: la comodidad de "acceso compartido" tiene un costo de superficie que no estaba midiendo.&lt;/p&gt;




&lt;h2&gt;
  
  
  FAQ: seguridad de datos en stacks de contratistas IA
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;¿Qué es exactamente lo que se robó en Mercor y por qué es grave?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Mercor es una plataforma que conecta empresas de IA con contratistas para tareas de etiquetado, evaluación y grabación de datos. Las 4TB robadas corresponden a muestras de voz recopiladas de aproximadamente 40.000 trabajadores. La gravedad es doble: primero, las grabaciones de voz son datos biométricos — son irrevocables, no podés cambiarle la voz a alguien como se cambia una contraseña. Segundo, esas muestras incluyen metadatos (nombre, ubicación, dispositivo) que permiten construir perfiles completos de personas que generalmente trabajan en economías vulnerables.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;¿Cómo puede afectar esto a alguien que no usa Mercor pero trabaja con datos de entrenamiento?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;El vector es el mismo aunque la plataforma sea distinta. Si almacenás datasets de entrenamiento en buckets con acceso amplio, si emitís tokens de larga duración a colaboradores externos, o si loggueás metadata de usuarios sin sanitizar, tenés la misma superficie. El nombre de la empresa comprometida cambia; la arquitectura de riesgo es idéntica.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;¿Qué diferencia hay entre este robo y un breach de credenciales común?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;La diferencia principal es la naturaleza irreversible del dato. Cuando te roban una contraseña, la cambiás. Cuando te roban una muestra de voz entrenada sobre miles de horas de grabación, no hay revocación posible. Además, los datos de entrenamiento de IA tienen valor de mercado muy específico: sirven para entrenar modelos de clonación de voz, sistemas de autenticación biométrica falsificados, y para evadir sistemas de detección de deepfakes. El mercado para eso existe y paga bien.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;¿Es suficiente con rotar tokens regularmente para estar protegido?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No, pero es el primer escalón. La rotación de tokens ataca el problema de credenciales de larga duración, pero no resuelve el acceso amplio, los logs con información sensible, o la ausencia de alertas por comportamiento anómalo. Es necesario también: políticas de acceso mínimo (least privilege), alertas sobre patrones de descarga fuera de baseline, y separación estricta entre ambientes de desarrollo y producción.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;¿Qué datos de uso de API de LLMs estoy exponiendo sin saberlo?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Más de lo que creés. Típicamente: prompts completos de usuarios en logs de debugging, tokens de autenticación en headers logueados, patrones de comportamiento que permiten identificar usuarios aunque no guardés PII explícita, y artefactos intermedios de procesamiento que pueden incluir fragmentos de datos de entrenamiento. Corrí la auditoría descrita en este post y encontré 23 tokens activos sin expiración y logs con Authorization headers en texto plano. No es inusual — es el default si no configurás activamente lo contrario.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;¿Debería preocuparme si soy un desarrollador indie sin datos de voz?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Sí, pero con proporción. No tenés el mismo riesgo que Mercor. Pero si usás APIs de LLMs, tenés tokens. Si tenés tokens, tenés credenciales que pueden comprometerse. Si loggueás requests para debugging — y casi todos lo hacemos — tenés potencial exposición de datos de usuarios. La escala cambia, el patrón no. El ejercicio mínimo útil: auditar cuántos tokens activos tenés hoy, cuántos tienen fecha de expiración, y qué estás logueando en producción.&lt;/p&gt;




&lt;h2&gt;
  
  
  Lo incómodo que no voy a suavizar
&lt;/h2&gt;

&lt;p&gt;Cuando terminé la simulación, había encontrado 23 tokens sin expiración, logs con headers de autenticación en texto plano, y preview deployments con acceso a datos de entrenamiento de producción. No sufrí un breach. Pero si alguien hubiera comprometido uno de esos tokens antes de que yo los rotara, el daño era real y silencioso.&lt;/p&gt;

&lt;p&gt;Lo que me quedo de Mercor no es la indignación moral — aunque 4TB de voz de 40k contratistas es un daño concreto a personas reales. Lo que me quedo es que &lt;strong&gt;el ecosistema de datos de entrenamiento IA construyó su infraestructura de acceso para velocidad, no para resistencia&lt;/strong&gt;. Y cuando ese modelo escala a millones de contratistas distribuidos globalmente, la superficie de ataque crece más rápido que los controles.&lt;/p&gt;

&lt;p&gt;Mi postura es esta: si construís pipelines de datos IA — aunque sea a escala indie — la auditoría de credenciales y permisos no es una tarea para "cuando tenga tiempo". Es una deuda técnica que, si no la pagás, alguien más la cobra por vos.&lt;/p&gt;

&lt;p&gt;El mismo patrón que &lt;a href="https://juanchi.dev/es/blog/godaddy-domain-hijacking-security-simulacion-ataque-infra-propia" rel="noopener noreferrer"&gt;me enseñó el ataque a mi dominio de GoDaddy&lt;/a&gt; aplica acá: el breach no ocurre donde ponés atención. Ocurre en el token que olvidaste, el log que nunca revisaste, el bucket que dejaste con permisos anchos "por las dudas".&lt;/p&gt;

&lt;p&gt;Andá a contar tus tokens activos. Yo conté 23. ¿Vos cuántos tenés?&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Este artículo fue publicado originalmente en &lt;a href="https://juanchi.dev/es/blog/mercor-robo-datos-voz-contratistas-ia-simulacion-stack" rel="noopener noreferrer"&gt;juanchi.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>spanish</category>
      <category>espanol</category>
      <category>railway</category>
      <category>privacidad</category>
    </item>
    <item>
      <title>pgbackrest is unmaintained: what I'm doing with my Postgres backups in production now</title>
      <dc:creator>Juan Torchia</dc:creator>
      <pubDate>Tue, 28 Apr 2026 14:30:42 +0000</pubDate>
      <link>https://forem.com/jtorchia/pgbackrest-is-unmaintained-what-im-doing-with-my-postgres-backups-in-production-now-59ei</link>
      <guid>https://forem.com/jtorchia/pgbackrest-is-unmaintained-what-im-doing-with-my-postgres-backups-in-production-now-59ei</guid>
      <description>&lt;h1&gt;
  
  
  pgbackrest is unmaintained: what I'm doing with my Postgres backups in production now
&lt;/h1&gt;

&lt;p&gt;A backup is basically like writing a phone number on a napkin. The napkin exists, the number is there, you feel covered. But the day you actually need to call and the napkin has dissolved at the bottom of a jeans pocket that went through the wash — that's the day you realize you never had a recovery plan. You had the illusion of one.&lt;/p&gt;

&lt;p&gt;That's what the HN thread about pgbackrest made me see: I had a wet napkin.&lt;/p&gt;




&lt;h2&gt;
  
  
  pgbackrest alternative postgres backup production: the context that actually matters
&lt;/h2&gt;

&lt;p&gt;The thread hit 425 points on Hacker News with a comment that left little room for doubt: the primary maintainer no longer has time, PRs are piling up unreviewed, and the project's direction is on indefinite pause. It's not an abandoned repo yet — but it's not something you'd want to stake a database holding real user data on.&lt;/p&gt;

&lt;p&gt;I was using it. Not as a first line, but as part of the incremental backup flow I built two years ago after &lt;a href="https://juanchi.dev/en/blog/ai-agent-deleted-production-database-logs-guardrails-real-analysis" rel="noopener noreferrer"&gt;an AI agent deleted my production database&lt;/a&gt;. After that episode I swore I'd never depend on a single recovery mechanism again. pgbackrest was the layer handling incremental backups with compression and time-based retention. It worked. Until it stopped making sense to keep depending on something with no active maintainer.&lt;/p&gt;

&lt;p&gt;My thesis: &lt;strong&gt;the death of an infra project isn't the problem itself — it's the smoke detector telling you you've never actually tested recovering anything&lt;/strong&gt;. The problem existed before. The HN thread just made it visible.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I evaluated and how I thought about it
&lt;/h2&gt;

&lt;p&gt;Before jumping to whatever the trendy alternative was, I forced myself to define what I actually needed. My stack: PostgreSQL 16 running on Railway, ~4.2 GB database, WAL archiving enabled, 30-day retention, and an informal RTO of "under 2 hours" that I had never concretely measured.&lt;/p&gt;

&lt;p&gt;Three options I evaluated seriously:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. WAL-G
&lt;/h3&gt;

&lt;p&gt;Open source, actively maintained by Wal-G Inc. (formerly part of the Citus/Microsoft stack), native support for S3, GCS, Azure, and local filesystem. The most concrete advantage: the binary is self-contained — no weird dependencies to wrangle.&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;# Basic install on Debian/Ubuntu&lt;/span&gt;
curl &lt;span class="nt"&gt;-L&lt;/span&gt; https://github.com/wal-g/wal-g/releases/latest/download/wal-g-pg-ubuntu-20.04-amd64.tar.gz &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;tar&lt;/span&gt; &lt;span class="nt"&gt;-xz&lt;/span&gt; &lt;span class="nt"&gt;-C&lt;/span&gt; /usr/local/bin/

&lt;span class="c"&gt;# Minimum environment variables for S3&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;WALG_S3_PREFIX&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"s3://my-backup-bucket/postgres"&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;AWS_ACCESS_KEY_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;AWS_SECRET_ACCESS_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;PGDATA&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"/var/lib/postgresql/16/main"&lt;/span&gt;

&lt;span class="c"&gt;# Full base backup&lt;/span&gt;
wal-g backup-push &lt;span class="nv"&gt;$PGDATA&lt;/span&gt;

&lt;span class="c"&gt;# List available backups&lt;/span&gt;
wal-g backup-list DETAIL

&lt;span class="c"&gt;# Restore to a specific point in time&lt;/span&gt;
wal-g backup-fetch &lt;span class="nv"&gt;$PGDATA&lt;/span&gt; LATEST
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What surprised me: in my restore tests, WAL-G took &lt;strong&gt;18 minutes&lt;/strong&gt; to recover the 4.2 GB from S3, including WAL application up to the target point in time. I measured that number three times with a simple script.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Barman (Backup and Recovery Manager)
&lt;/h3&gt;

&lt;p&gt;Maintained by EnterpriseDB, far more mature in terms of operational interface. The configuration curve is steep — there's a separate Barman server acting as backup receiver, which means additional infrastructure.&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;# Basic barman.conf (on the dedicated Barman server)&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt;barman]
barman_home &lt;span class="o"&gt;=&lt;/span&gt; /var/lib/barman
barman_user &lt;span class="o"&gt;=&lt;/span&gt; barman
log_file &lt;span class="o"&gt;=&lt;/span&gt; /var/log/barman/barman.log
compression &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;gzip
&lt;/span&gt;reuse_backup &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;link&lt;/span&gt;  &lt;span class="c"&gt;# hardlinks for efficient incremental backups&lt;/span&gt;

&lt;span class="o"&gt;[&lt;/span&gt;my-postgres-server]
description &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Main production"&lt;/span&gt;
conninfo &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;host&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;postgres-host &lt;span class="nv"&gt;user&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;barman &lt;span class="nv"&gt;dbname&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;postgres
backup_method &lt;span class="o"&gt;=&lt;/span&gt; rsync
archiver &lt;span class="o"&gt;=&lt;/span&gt; on
retention_policy &lt;span class="o"&gt;=&lt;/span&gt; RECOVERY WINDOW OF 30 DAYS

&lt;span class="c"&gt;# Check configuration&lt;/span&gt;
barman check my-postgres-server

&lt;span class="c"&gt;# Backup&lt;/span&gt;
barman backup my-postgres-server

&lt;span class="c"&gt;# List&lt;/span&gt;
barman list-backup my-postgres-server

&lt;span class="c"&gt;# Restore&lt;/span&gt;
barman recover my-postgres-server latest /var/lib/postgresql/16/main &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--target-time&lt;/span&gt; &lt;span class="s2"&gt;"2025-07-10 14:30:00"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Barman's restore time in the same scenario: &lt;strong&gt;31 minutes&lt;/strong&gt;. Nearly double. The main reason is that Barman uses rsync by default and has coordination overhead between servers. With &lt;code&gt;backup_method = postgres&lt;/code&gt; (streaming) it comes down, but it still doesn't win.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Plain pg_dump with manual rotation
&lt;/h3&gt;

&lt;p&gt;The most honest option. The one everyone knows, nobody wants to use for serious production, and the one that often is the only thing left standing when everything else fails.&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;#!/bin/bash&lt;/span&gt;
&lt;span class="c"&gt;# pg_dump backup script — no magic, no external dependencies&lt;/span&gt;
&lt;span class="c"&gt;# Saved as /usr/local/bin/pg_daily_backup.sh&lt;/span&gt;

&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail

&lt;span class="nv"&gt;TIMESTAMP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%Y%m%d_%H%M%S&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;DB_NAME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"my_database"&lt;/span&gt;
&lt;span class="nv"&gt;BACKUP_DIR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"/mnt/backups/postgres"&lt;/span&gt;
&lt;span class="nv"&gt;RETENTION_DAYS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;14

&lt;span class="c"&gt;# Compressed backup&lt;/span&gt;
pg_dump &lt;span class="nt"&gt;-Fc&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--no-password&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-h&lt;/span&gt; &lt;span class="nv"&gt;$PGHOST&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-U&lt;/span&gt; &lt;span class="nv"&gt;$PGUSER&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nv"&gt;$DB_NAME&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BACKUP_DIR&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;DB_NAME&lt;/span&gt;&lt;span class="k"&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;TIMESTAMP&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.dump"&lt;/span&gt;

&lt;span class="c"&gt;# Automatic rotation&lt;/span&gt;
find &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BACKUP_DIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-name&lt;/span&gt; &lt;span class="s2"&gt;"*.dump"&lt;/span&gt; &lt;span class="nt"&gt;-mtime&lt;/span&gt; +&lt;span class="nv"&gt;$RETENTION_DAYS&lt;/span&gt; &lt;span class="nt"&gt;-delete&lt;/span&gt;

&lt;span class="c"&gt;# Log the actual dump size&lt;/span&gt;
&lt;span class="nb"&gt;du&lt;/span&gt; &lt;span class="nt"&gt;-sh&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BACKUP_DIR&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;DB_NAME&lt;/span&gt;&lt;span class="k"&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;TIMESTAMP&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.dump"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; /var/log/pg_backup.log

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Backup completed: &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;TIMESTAMP&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; /var/log/pg_backup.log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Restore with pg_dump is &lt;strong&gt;23 minutes&lt;/strong&gt; for the 4.2 GB. Faster than Barman, slower than WAL-G — but with no PITR (Point in Time Recovery). If you need to recover to 14:37 and your closest backup is from 14:00, you just lost 37 minutes of data. That trade-off is the one that really stings in real production.&lt;/p&gt;




&lt;h2&gt;
  
  
  What popularity benchmarks don't tell you
&lt;/h2&gt;

&lt;p&gt;The problem with choosing infra tools by GitHub stars or how often people mention them on Reddit is that popularity measures adoption, not fitness for your specific case. pgbackrest has over 3,000 stars. That did nothing for me when I needed to understand how long my particular database would actually take to recover.&lt;/p&gt;

&lt;p&gt;What helped: measuring. Three distinct scenarios:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Full restore (4.2 GB)&lt;/th&gt;
&lt;th&gt;PITR available&lt;/th&gt;
&lt;th&gt;Infra overhead&lt;/th&gt;
&lt;th&gt;Storage cost (30 days)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;WAL-G&lt;/td&gt;
&lt;td&gt;18 min&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Minimal&lt;/td&gt;
&lt;td&gt;~$0.92/mo on S3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Barman&lt;/td&gt;
&lt;td&gt;31 min&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Dedicated server&lt;/td&gt;
&lt;td&gt;~$0.92/mo + EC2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pg_dump&lt;/td&gt;
&lt;td&gt;23 min&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;~$0.85/mo on S3&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Storage cost on S3 is nearly identical because WAL-G compresses aggressively and archived WALs are reasonably small for a database without massive write throughput. But if Railway or Supabase were my managed Postgres option, external WAL archiving either comes pre-solved by the platform or simply isn't available for manual configuration.&lt;/p&gt;

&lt;p&gt;That detail made me revisit &lt;a href="https://juanchi.dev/en/blog/godaddy-domain-hijacking-simulated-attack-own-infra" rel="noopener noreferrer"&gt;why I moved certain things to my own infrastructure&lt;/a&gt; — control over how and where you store your recovery data is not a minor concern when the provider decides which features you get to touch.&lt;/p&gt;




&lt;h2&gt;
  
  
  The mistakes I made (and that you'll make if you don't check right now)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Mistake 1: I never actually restored anything
&lt;/h3&gt;

&lt;p&gt;I'd had backups running for two years. I never did a full restore to a staging environment to measure real time. The number I had in my head ("Postgres backup, under an hour") was completely made up. My first real restore to a clean environment took 47 minutes with pgbackrest — almost double what I'd assumed.&lt;/p&gt;

&lt;p&gt;If you haven't run a full restore in the last month, you don't have a recovery plan. You have a wet napkin.&lt;/p&gt;

&lt;h3&gt;
  
  
  Mistake 2: confusing backup with archive
&lt;/h3&gt;

&lt;p&gt;WAL archiving and base backups are two different things that work together. If you only have WAL archiving without a recent base backup, restore time will be proportional to how many WAL files you need to apply from the last base backup. In my case, with a weekly base backup and continuous WAL, the worst case was 7 days of WAL — several additional minutes of replay.&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;# See how many WAL segments exist since the last backup&lt;/span&gt;
&lt;span class="c"&gt;# In WAL-G:&lt;/span&gt;
wal-g wal-show

&lt;span class="c"&gt;# Expected output — pay attention to the "segments" count&lt;/span&gt;
&lt;span class="c"&gt;# +---------------------------+----------+---------+&lt;/span&gt;
&lt;span class="c"&gt;# | Start                     | End      |Segments |&lt;/span&gt;
&lt;span class="c"&gt;# +---------------------------+----------+---------+&lt;/span&gt;
&lt;span class="c"&gt;# | 2025-07-04T03:00:00+00:00 | current  |    1842 |&lt;/span&gt;
&lt;span class="c"&gt;# +---------------------------+----------+---------+&lt;/span&gt;
&lt;span class="c"&gt;# 1842 segments = non-trivial replay time&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Mistake 3: ignoring WAL size in production
&lt;/h3&gt;

&lt;p&gt;My database is 4.2 GB of data, but it generates roughly 180 MB of WAL per day. Over 30 days: ~5.4 GB of additional archived WAL. If you don't measure it, the storage cost creeps up silently. On S3 it's cheap — on other providers it can surprise you.&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="nt"&gt;--&lt;/span&gt; Measure WAL generation &lt;span class="k"&gt;in &lt;/span&gt;the last 24 hours
SELECT
  count&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; as wal_files_generated,
  pg_size_pretty&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;sum&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;size&lt;span class="o"&gt;))&lt;/span&gt; as total_size
FROM pg_ls_waldir&lt;span class="o"&gt;()&lt;/span&gt;
WHERE modification &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; now&lt;span class="o"&gt;()&lt;/span&gt; - interval &lt;span class="s1"&gt;'24 hours'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This kind of measurement is exactly what &lt;a href="https://juanchi.dev/en/blog/ai-agent-deleted-production-database-logs-guardrails-real-analysis" rel="noopener noreferrer"&gt;production logs reveal&lt;/a&gt; when you force yourself to look at them cold, without the adrenaline of an active incident.&lt;/p&gt;




&lt;h2&gt;
  
  
  FAQ: pgbackrest alternative postgres backup production
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Is WAL-G a drop-in replacement for pgbackrest?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Functionally yes, in most cases. Both handle base backups + WAL archiving with PITR. The main difference is in configuration: WAL-G is simpler to get started (one binary, environment variables) while pgbackrest has a more expressive config file. If you already have pgbackrest configured, migrating to WAL-G means rewriting the config and doing a full base backup from scratch — you can't reuse existing backups in a different format.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is Barman still worth it if you already have dedicated infra?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes, especially if you manage multiple Postgres instances and need a centralized operational interface with auditing. The overhead of a separate Barman server pays off when you're managing 5+ instances from one place. For a single instance like mine, it's overkill with a real cost.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is pg_dump enough for production or just for development?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Depends on your RTO and RPO. If you can tolerate data loss up to N hours (where N is your dump frequency) and a 20–40 minute restore doesn't break any SLA, pg_dump with automated rotation is completely valid. The real limitation is the absence of PITR: you can't recover to an exact point between two dumps. For critical transactional databases, that's usually unacceptable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I configure WAL archiving on Railway or Supabase?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;On Railway with a custom Postgres install you can configure &lt;code&gt;archive_mode = on&lt;/code&gt; and &lt;code&gt;archive_command&lt;/code&gt; if you have access to &lt;code&gt;postgresql.conf&lt;/code&gt;. On Supabase, WAL archiving is internal to the service — you can use Point in Time Recovery within the platform but you can't export WAL to external storage directly. That's a recovery vendor lock-in worth evaluating based on how critical your data is.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What base backup frequency makes sense?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For most cases: daily base backup + continuous WAL. A weekly base backup with continuous WAL is acceptable if the database grows slowly (under 500 MB/day of WAL). With daily base backups, WAL replay during restore is minimal. With weekly, in the worst case you need to apply 7 days of WAL — that can add tens of minutes depending on write volume.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is it worth waiting to see if pgbackrest picks up maintenance again?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No. In infra systems, a maintainer who announces they don't have time rarely comes back with more energy. The window between "project without active maintenance" and "critical vulnerability with no patch" can be short. Migrate now, with time, not during an incident. The cost of migrating calmly is infinitely lower than migrating under pressure — something I learned the night I &lt;a href="https://juanchi.dev/en/blog/asahi-linux-70-apple-silicon-installed-measured-real-workflow" rel="noopener noreferrer"&gt;wiped a production server with rm -rf in my first week on the job&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I chose and why it's not the right answer for everyone
&lt;/h2&gt;

&lt;p&gt;I went with &lt;strong&gt;WAL-G + pg_dump as a second line&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;WAL-G handles incremental backups with PITR. pg_dump runs every 24 hours to a separate S3 bucket as an independent fallback — no third-party tool dependencies, no special binaries, just &lt;code&gt;pg_dump&lt;/code&gt; and &lt;code&gt;aws s3 cp&lt;/code&gt;. If WAL-G disappeared tomorrow, I have yesterday's dump.&lt;/p&gt;

&lt;p&gt;The criterion I used wasn't "which tool has more stars" — it was "how fast can I recover and how many dependencies are in the recovery chain." Fewer dependencies in the critical path is better. When you have to restore a database, every additional piece that can fail is a problem you don't need.&lt;/p&gt;

&lt;p&gt;What I wouldn't choose for my case: Barman on a single instance. The operational overhead doesn't close the deal. It can make sense for teams with multiple databases and a dedicated DBA — but that's not my reality.&lt;/p&gt;

&lt;p&gt;The uncomfortable part of all this: I spent two years with pgbackrest without ever measuring a real restore. The HN thread didn't break my infrastructure — it broke my false sense of security. And in the long run, that was better than keeping a wet napkin in my pocket.&lt;/p&gt;

&lt;p&gt;If you want to audit what else might be in "works until it doesn't" state in your data layer, the post about &lt;a href="https://juanchi.dev/en/blog/plain-text-won-migrating-notion-to-markdown-what-i-lost" rel="noopener noreferrer"&gt;migrating from Notion to Markdown&lt;/a&gt; has some of that same flavor: the silent dependency that only hurts when you try to leave.&lt;/p&gt;

&lt;p&gt;And if you make the switch to WAL-G, measure the restore. Don't assume it. The real number is always different from the imagined one.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Migrating from pgbackrest or evaluating your options? Reach out — I'm putting together a repo of real WAL-G configs specifically for Railway.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This article was originally published on &lt;a href="https://juanchi.dev/en/blog/pgbackrest-unmaintained-postgres-backup-alternatives-production" rel="noopener noreferrer"&gt;juanchi.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>english</category>
      <category>devops</category>
      <category>produccion</category>
      <category>railway</category>
    </item>
    <item>
      <title>pgbackrest dejó de mantenerse: qué hago ahora con mis backups de Postgres en producción</title>
      <dc:creator>Juan Torchia</dc:creator>
      <pubDate>Tue, 28 Apr 2026 14:30:38 +0000</pubDate>
      <link>https://forem.com/jtorchia/pgbackrest-dejo-de-mantenerse-que-hago-ahora-con-mis-backups-de-postgres-en-produccion-ccd</link>
      <guid>https://forem.com/jtorchia/pgbackrest-dejo-de-mantenerse-que-hago-ahora-con-mis-backups-de-postgres-en-produccion-ccd</guid>
      <description>&lt;h1&gt;
  
  
  pgbackrest dejó de mantenerse: qué hago ahora con mis backups de Postgres en producción
&lt;/h1&gt;

&lt;p&gt;Hacer un backup es básicamente como anotarte un número de teléfono en una servilleta. La servilleta existe, el número está ahí, sentís que estás cubierto. Pero el día que necesitás llamar y la servilleta desapareció en el fondo de un bolsillo de jean que pasó por el lavarropas — ese día entendés que nunca tuviste un plan de recuperación. Tenías una ilusión de plan.&lt;/p&gt;

&lt;p&gt;Eso es lo que el thread de HN sobre pgbackrest me hizo caer: yo tenía una servilleta mojada.&lt;/p&gt;




&lt;h2&gt;
  
  
  pgbackrest alternativa postgres backup producción: el contexto que importa
&lt;/h2&gt;

&lt;p&gt;El thread llegó a 425 puntos en Hacker News con un comentario que no dejaba mucho lugar a la duda: el mantenedor principal ya no tiene tiempo, los PRs se acumulan sin revisión y la dirección del proyecto está en pausa indefinida. No es un repo abandonado todavía, pero tampoco es algo en lo que querrías apoyar una base de datos que aloja datos de usuarios reales.&lt;/p&gt;

&lt;p&gt;Yo lo usaba. No como primera línea, pero sí como parte del flujo de backup incremental que armé hace dos años cuando &lt;a href="https://juanchi.dev/es/blog/agente-ia-borro-base-datos-produccion-logs-guardrails" rel="noopener noreferrer"&gt;el agente me borró la base de datos en producción&lt;/a&gt;. Después de ese episodio prometí que nunca más iba a depender de un solo mecanismo de recovery. pgbackrest era la capa que manejaba los backups incrementales con compresión y retención por tiempo. Funcionaba. Hasta que dejó de tener sentido seguir dependiendo de algo sin maintainer activo.&lt;/p&gt;

&lt;p&gt;Mi tesis: &lt;strong&gt;la muerte de un proyecto de infra no es el problema en sí — es el detector de humo que te avisa que nunca probaste realmente recuperar nada&lt;/strong&gt;. El problema estaba antes. El thread de HN solo lo hizo visible.&lt;/p&gt;




&lt;h2&gt;
  
  
  Qué opciones evalué y con qué criterio
&lt;/h2&gt;

&lt;p&gt;Antes de saltar a la alternativa de moda, me obligué a definir qué necesitaba realmente. Mi stack: PostgreSQL 16 corriendo en Railway, base de datos de ~4.2 GB, WAL archiving habilitado, retention de 30 días, RTO informal de "menos de 2 horas" que nunca había medido concretamente.&lt;/p&gt;

&lt;p&gt;Las tres opciones que evalué en serio:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. WAL-G
&lt;/h3&gt;

&lt;p&gt;Open source, mantenido activamente por Wal-G Inc. (antes parte del stack de Citus/Microsoft), soporte nativo para S3, GCS, Azure y filesystem local. La ventaja más concreta es que el binario es autocontenido — no hay dependencias raras que gestionar.&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;# Instalación básica en Debian/Ubuntu&lt;/span&gt;
curl &lt;span class="nt"&gt;-L&lt;/span&gt; https://github.com/wal-g/wal-g/releases/latest/download/wal-g-pg-ubuntu-20.04-amd64.tar.gz &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;tar&lt;/span&gt; &lt;span class="nt"&gt;-xz&lt;/span&gt; &lt;span class="nt"&gt;-C&lt;/span&gt; /usr/local/bin/

&lt;span class="c"&gt;# Variables de entorno mínimas para S3&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;WALG_S3_PREFIX&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"s3://mi-bucket-backups/postgres"&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;AWS_ACCESS_KEY_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;AWS_SECRET_ACCESS_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;PGDATA&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"/var/lib/postgresql/16/main"&lt;/span&gt;

&lt;span class="c"&gt;# Backup base completo&lt;/span&gt;
wal-g backup-push &lt;span class="nv"&gt;$PGDATA&lt;/span&gt;

&lt;span class="c"&gt;# Listar backups disponibles&lt;/span&gt;
wal-g backup-list DETAIL

&lt;span class="c"&gt;# Restore a punto específico en el tiempo&lt;/span&gt;
wal-g backup-fetch &lt;span class="nv"&gt;$PGDATA&lt;/span&gt; LATEST
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Lo que me sorprendió: en mis pruebas de restore, WAL-G tardó &lt;strong&gt;18 minutos&lt;/strong&gt; en recuperar los 4.2 GB desde S3 incluyendo aplicación de WAL hasta el punto en el tiempo que elegí. Ese número lo medí tres veces con un script simple.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Barman (Backup and Recovery Manager)
&lt;/h3&gt;

&lt;p&gt;Mantenido por EnterpriseDB, mucho más maduro en términos de interfaz operacional. La curva de configuración es empinada — hay un servidor Barman separado que actúa como receptor de backups, lo que implica infraestructura adicional.&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;# barman.conf básico (en el servidor Barman dedicado)&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt;barman]
barman_home &lt;span class="o"&gt;=&lt;/span&gt; /var/lib/barman
barman_user &lt;span class="o"&gt;=&lt;/span&gt; barman
log_file &lt;span class="o"&gt;=&lt;/span&gt; /var/log/barman/barman.log
compression &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;gzip
&lt;/span&gt;reuse_backup &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;link&lt;/span&gt;  &lt;span class="c"&gt;# hardlinks para backups incrementales eficientes&lt;/span&gt;

&lt;span class="o"&gt;[&lt;/span&gt;mi-postgres-server]
description &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Producción principal"&lt;/span&gt;
conninfo &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;host&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;postgres-host &lt;span class="nv"&gt;user&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;barman &lt;span class="nv"&gt;dbname&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;postgres
backup_method &lt;span class="o"&gt;=&lt;/span&gt; rsync
archiver &lt;span class="o"&gt;=&lt;/span&gt; on
retention_policy &lt;span class="o"&gt;=&lt;/span&gt; RECOVERY WINDOW OF 30 DAYS

&lt;span class="c"&gt;# Verificar configuración&lt;/span&gt;
barman check mi-postgres-server

&lt;span class="c"&gt;# Backup&lt;/span&gt;
barman backup mi-postgres-server

&lt;span class="c"&gt;# Listar&lt;/span&gt;
barman list-backup mi-postgres-server

&lt;span class="c"&gt;# Restore&lt;/span&gt;
barman recover mi-postgres-server latest /var/lib/postgresql/16/main &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--target-time&lt;/span&gt; &lt;span class="s2"&gt;"2025-07-10 14:30:00"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El tiempo de restore de Barman en el mismo escenario: &lt;strong&gt;31 minutos&lt;/strong&gt;. Casi el doble. La razón principal es que Barman usa rsync por defecto y tiene overhead de coordinación entre servidores. Con &lt;code&gt;backup_method = postgres&lt;/code&gt; (streaming) baja, pero igual no gana.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. pg_dump puro con rotación manual
&lt;/h3&gt;

&lt;p&gt;La opción más honesta. La que todo el mundo conoce, nadie quiere usar para producción seria, y que muchas veces es la única que sobrevive cuando todo lo demás falla.&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;#!/bin/bash&lt;/span&gt;
&lt;span class="c"&gt;# Script de backup con pg_dump — sin magia, sin dependencias externas&lt;/span&gt;
&lt;span class="c"&gt;# Guardado en /usr/local/bin/pg_backup_diario.sh&lt;/span&gt;

&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail

&lt;span class="nv"&gt;TIMESTAMP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%Y%m%d_%H%M%S&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;DB_NAME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"mi_base"&lt;/span&gt;
&lt;span class="nv"&gt;BACKUP_DIR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"/mnt/backups/postgres"&lt;/span&gt;
&lt;span class="nv"&gt;RETENTION_DAYS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;14

&lt;span class="c"&gt;# Backup comprimido&lt;/span&gt;
pg_dump &lt;span class="nt"&gt;-Fc&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--no-password&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-h&lt;/span&gt; &lt;span class="nv"&gt;$PGHOST&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-U&lt;/span&gt; &lt;span class="nv"&gt;$PGUSER&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nv"&gt;$DB_NAME&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BACKUP_DIR&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;DB_NAME&lt;/span&gt;&lt;span class="k"&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;TIMESTAMP&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.dump"&lt;/span&gt;

&lt;span class="c"&gt;# Rotación automática&lt;/span&gt;
find &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BACKUP_DIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-name&lt;/span&gt; &lt;span class="s2"&gt;"*.dump"&lt;/span&gt; &lt;span class="nt"&gt;-mtime&lt;/span&gt; +&lt;span class="nv"&gt;$RETENTION_DAYS&lt;/span&gt; &lt;span class="nt"&gt;-delete&lt;/span&gt;

&lt;span class="c"&gt;# Log del tamaño real del dump&lt;/span&gt;
&lt;span class="nb"&gt;du&lt;/span&gt; &lt;span class="nt"&gt;-sh&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BACKUP_DIR&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;DB_NAME&lt;/span&gt;&lt;span class="k"&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;TIMESTAMP&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.dump"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; /var/log/pg_backup.log

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Backup completado: &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;TIMESTAMP&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; /var/log/pg_backup.log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Restore con pg_dump es &lt;strong&gt;23 minutos&lt;/strong&gt; para los 4.2 GB. Más rápido que Barman, más lento que WAL-G, pero sin PITR (Point in Time Recovery). Si necesitás recuperar a las 14:37 y el backup más cercano es de las 14:00, perdiste 37 minutos de datos. Ese trade-off es el que más duele en producción real.&lt;/p&gt;




&lt;h2&gt;
  
  
  Lo que los benchmarks de popularidad no te dicen
&lt;/h2&gt;

&lt;p&gt;El problema con elegir herramientas de infra por GitHub stars o por cuánta gente las menciona en Reddit es que la popularidad mide adopción, no idoneidad para el caso específico. pgbackrest tiene más de 3.000 estrellas. Eso no me ayudó cuando necesité entender cuánto tarda mi base puntual en recuperarse.&lt;/p&gt;

&lt;p&gt;Lo que sí me ayudó: medir. Tres scenarios distintos:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Herramienta&lt;/th&gt;
&lt;th&gt;Restore completo (4.2 GB)&lt;/th&gt;
&lt;th&gt;PITR disponible&lt;/th&gt;
&lt;th&gt;Overhead infra&lt;/th&gt;
&lt;th&gt;Costo storage (30 días)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;WAL-G&lt;/td&gt;
&lt;td&gt;18 min&lt;/td&gt;
&lt;td&gt;Sí&lt;/td&gt;
&lt;td&gt;Mínimo&lt;/td&gt;
&lt;td&gt;~$0.92/mes en S3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Barman&lt;/td&gt;
&lt;td&gt;31 min&lt;/td&gt;
&lt;td&gt;Sí&lt;/td&gt;
&lt;td&gt;Servidor dedicado&lt;/td&gt;
&lt;td&gt;~$0.92/mes + EC2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pg_dump&lt;/td&gt;
&lt;td&gt;23 min&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Ninguno&lt;/td&gt;
&lt;td&gt;~$0.85/mes en S3&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;El costo de storage en S3 es casi el mismo porque WAL-G comprime agresivamente y los WAL archivados son razonablemente chicos para una base que no tiene escrituras masivas. Pero si Railway o Supabase fueran mi opción de managed Postgres, el WAL archiving externo ya viene resuelto o directamente no está disponible para configurar manualmente.&lt;/p&gt;

&lt;p&gt;Ese detalle me hizo revisar &lt;a href="https://juanchi.dev/es/blog/godaddy-domain-hijacking-security-simulacion-ataque-infra-propia" rel="noopener noreferrer"&gt;por qué migré ciertas cosas a infra propia&lt;/a&gt; — el control sobre cómo y dónde guardás los datos de recovery no es un tema menor cuando el proveedor decide qué features exponés.&lt;/p&gt;




&lt;h2&gt;
  
  
  Los errores que cometí (y que vas a cometer si no los revisás ahora)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Error 1: nunca restauré de verdad
&lt;/h3&gt;

&lt;p&gt;Tenía backups funcionando desde hacía dos años. Nunca hice un restore completo a un ambiente de staging para medir el tiempo real. El número que tenía en la cabeza ("backup de Postgres, menos de una hora") era completamente inventado. Mi primer restore real en un ambiente limpio tardó 47 minutos con pgbackrest — casi el doble de lo que asumía.&lt;/p&gt;

&lt;p&gt;Si no corriste un restore completo en el último mes, no tenés un plan de recovery. Tenés una servilleta mojada.&lt;/p&gt;

&lt;h3&gt;
  
  
  Error 2: confundir backup con archive
&lt;/h3&gt;

&lt;p&gt;WAL archiving y backups base son dos cosas distintas que trabajan juntas. Si solo tenés WAL archiving sin un backup base reciente, el tiempo de restore va a ser proporcional a cuántos WAL files necesitás aplicar desde el último base backup. En mi caso, con un base backup semanal y WAL continuo, el peor escenario eran 7 días de WAL — varios minutos adicionales de replay.&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;# Ver cuántos WAL segments hay desde el último backup&lt;/span&gt;
&lt;span class="c"&gt;# En WAL-G:&lt;/span&gt;
wal-g wal-show

&lt;span class="c"&gt;# Salida esperada — prestá atención al "segments" count&lt;/span&gt;
&lt;span class="c"&gt;# +---------------------------+----------+---------+&lt;/span&gt;
&lt;span class="c"&gt;# | Start                     | End      |Segments |&lt;/span&gt;
&lt;span class="c"&gt;# +---------------------------+----------+---------+&lt;/span&gt;
&lt;span class="c"&gt;# | 2025-07-04T03:00:00+00:00 | current  |    1842 |&lt;/span&gt;
&lt;span class="c"&gt;# +---------------------------+----------+---------+&lt;/span&gt;
&lt;span class="c"&gt;# 1842 segments = tiempo de replay no trivial&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Error 3: ignorar el WAL size en producción
&lt;/h3&gt;

&lt;p&gt;Mi base tiene 4.2 GB de datos, pero genera aproximadamente 180 MB de WAL por día. En 30 días: ~5.4 GB de WAL adicional archivado. Si no lo medís, el costo de storage se va silenciosamente. En S3 es barato, pero en otros providers puede sorprender.&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;# Medir generación de WAL en las últimas 24hs&lt;/span&gt;
SELECT
  count&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; as wal_files_generados,
  pg_size_pretty&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;sum&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;size&lt;span class="o"&gt;))&lt;/span&gt; as tamanio_total
FROM pg_ls_waldir&lt;span class="o"&gt;()&lt;/span&gt;
WHERE modification &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; now&lt;span class="o"&gt;()&lt;/span&gt; - interval &lt;span class="s1"&gt;'24 hours'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Este tipo de medición es exactamente lo que &lt;a href="https://juanchi.dev/es/blog/agente-ia-borro-base-datos-produccion-logs-guardrails" rel="noopener noreferrer"&gt;los logs de producción revelan&lt;/a&gt; cuando te forzás a mirarlos en frío, sin la adrenalina de un incidente.&lt;/p&gt;




&lt;h2&gt;
  
  
  FAQ: pgbackrest alternativa postgres backup producción
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;¿WAL-G es un reemplazo directo de pgbackrest?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Funcionalmente sí, en la mayoría de los casos. Ambos manejan backups base + WAL archiving con PITR. La diferencia principal está en la configuración: WAL-G es más simple de arrancar (un binario, variables de entorno) mientras que pgbackrest tiene un archivo de configuración más expresivo. Si ya tenés pgbackrest configurado, la migración a WAL-G implica reescribir la config y hacer un primer backup base completo desde cero — no podés reutilizar los backups existentes de formato distinto.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;¿Barman sigue valiendo la pena si ya tenés infra dedicada?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Sí, especialmente si manejás múltiples instancias de Postgres y necesitás una interfaz operacional centralizada con auditoría. El overhead de tener un servidor Barman separado se amortiza cuando gestionás 5+ instancias desde un solo lugar. Para una sola instancia como la mía, es overkill con costo real.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;¿pg_dump alcanza para producción o es solo para desarrollo?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Depende de tu RTO y RPO. Si podés tolerar pérdida de hasta N horas de datos (donde N es la frecuencia de dumps) y un restore de 20-40 minutos no te rompe ningún SLA, pg_dump con rotación automatizada es completamente válido. La limitación real es la ausencia de PITR: no podés recuperar a un punto exacto entre dos dumps. Para bases transaccionales críticas, eso suele ser inaceptable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;¿Cómo configuro WAL archiving en Railway o Supabase?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;En Railway con Postgres custom podés configurar &lt;code&gt;archive_mode = on&lt;/code&gt; y &lt;code&gt;archive_command&lt;/code&gt; si tenés acceso al &lt;code&gt;postgresql.conf&lt;/code&gt;. En Supabase el WAL archiving es interno al servicio — podés usar Point in Time Recovery dentro de la plataforma pero no exportar WAL a storage externo directamente. Eso es un vendor lock-in de recovery que vale la pena evaluar según la criticidad del dato.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;¿Qué frecuencia de backup base tiene sentido?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Para la mayoría de los casos: backup base diario + WAL continuo. Un backup base semanal con WAL continuo es aceptable si la base crece lento (bajo 500 MB/día de WAL). Con backup base diario, el replay de WAL en restore es mínimo. Con backup semanal, en el peor caso necesitás aplicar 7 días de WAL — eso puede sumar decenas de minutos dependiendo del volumen de escritura.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;¿Vale la pena esperar a ver si pgbackrest retoma el mantenimiento?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No. En sistemas de infra, un maintainer que anuncia falta de tiempo disponible raramente vuelve con más energía. La ventana de riesgo entre "proyecto sin mantenimiento activo" y "vulnerabilidad crítica sin parchear" puede ser corta. Migrá ahora con tiempo, no durante un incidente. El costo de migrar en calma es infinitamente menor que el costo de migrar bajo presión — algo que aprendí la noche que &lt;a href="https://juanchi.dev/es/blog/asahi-linux-70-apple-silicon-instalacion-kernel-arm" rel="noopener noreferrer"&gt;tiré un servidor de producción con rm -rf en mi primera semana de laburo&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Qué elegí y por qué no es la respuesta para todos
&lt;/h2&gt;

&lt;p&gt;Me quedé con &lt;strong&gt;WAL-G + pg_dump como segunda línea&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;WAL-G maneja los backups incrementales con PITR. pg_dump corre cada 24 horas y va a un bucket S3 separado como fallback independiente — sin dependencias de herramientas de terceros, sin binarios especiales, solo &lt;code&gt;pg_dump&lt;/code&gt; y &lt;code&gt;aws s3 cp&lt;/code&gt;. Si WAL-G desapareciera mañana, tengo un dump de ayer.&lt;/p&gt;

&lt;p&gt;El criterio que usé no fue "qué tool tiene más stars" sino "qué tan rápido puedo recuperar y con cuántas dependencias en la cadena de recovery". Menos dependencias en el path crítico es mejor. Cuando tenés que restaurar una base de datos, cada pieza adicional que puede fallar es un problema que no necesitás.&lt;/p&gt;

&lt;p&gt;Lo que no elegiría para mi caso: Barman en una sola instancia. El overhead operacional no cierra. Puede tener sentido para equipos con múltiples bases y un DBA dedicado — pero eso no es mi realidad.&lt;/p&gt;

&lt;p&gt;Lo incómodo de todo esto: pasé dos años con pgbackrest sin haber medido un restore real. El thread de HN no me rompió la infra — me rompió la tranquilidad falsa. Y eso, a la larga, fue mejor que seguir con una servilleta mojada en el bolsillo.&lt;/p&gt;

&lt;p&gt;Si querés revisar qué más puede estar en estado de "funciona hasta que no funciona" en la capa de datos, el post sobre &lt;a href="https://juanchi.dev/es/blog/migrar-notion-markdown-plain-text-lo-que-perdi" rel="noopener noreferrer"&gt;migrar de Notion a Markdown&lt;/a&gt; tiene algo de ese mismo sabor: la dependencia silenciosa que solo duele cuando intentás salir.&lt;/p&gt;

&lt;p&gt;Y si hacés el switch a WAL-G, medí el restore. No lo asumas. El número real siempre es distinto del número imaginado.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;¿Estás migrando de pgbackrest o evaluando opciones? Escribime — estoy armando un repositorio de configuraciones reales de WAL-G para Railway específicamente.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Este artículo fue publicado originalmente en &lt;a href="https://juanchi.dev/es/blog/pgbackrest-alternativa-postgres-backup-produccion" rel="noopener noreferrer"&gt;juanchi.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>spanish</category>
      <category>espanol</category>
      <category>devops</category>
      <category>produccion</category>
    </item>
    <item>
      <title>Microsoft and OpenAI Break Their Exclusive Deal: What My API Usage Logs Actually Say About Who Benefits</title>
      <dc:creator>Juan Torchia</dc:creator>
      <pubDate>Tue, 28 Apr 2026 12:31:22 +0000</pubDate>
      <link>https://forem.com/jtorchia/microsoft-and-openai-break-their-exclusive-deal-what-my-api-usage-logs-actually-say-about-who-3m68</link>
      <guid>https://forem.com/jtorchia/microsoft-and-openai-break-their-exclusive-deal-what-my-api-usage-logs-actually-say-about-who-3m68</guid>
      <description>&lt;h1&gt;
  
  
  Microsoft and OpenAI Break Their Exclusive Deal: What My API Usage Logs Actually Say About Who Benefits
&lt;/h1&gt;

&lt;p&gt;Back in 2009, at 18, I was studying for my CCNA at night after eight hours working at a cybercafé. Cisco had a near-monopoly on enterprise networking in Argentina. I remember thinking: "if Cisco breaks something with a partner, why should I care? I still need to know OSPF." Today, reading that Microsoft and OpenAI dissolved their exclusivity agreement — the big news that dominated Hacker News with 880 points in just a few hours — I got exactly the same feeling. Tons of noise at the top. Down in my logs, the story is more boring and more honest.&lt;/p&gt;

&lt;p&gt;But there's one exception. And I found it on my March invoice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Microsoft and OpenAI Exclusivity Deal: What Actually Changed and What Didn't
&lt;/h2&gt;

&lt;p&gt;For anyone who already has the week's context: I'm not going to rehash the 2019 deal, the $13 billion, or the distribution rights. That's all out there. What's new is that Microsoft no longer holds exclusivity over the OpenAI API for cloud providers. Any other vendor — Google Cloud, AWS, Oracle — can now offer direct access to GPT-4o, o1, or whatever comes next, without going through Azure OpenAI Service.&lt;/p&gt;

&lt;p&gt;My concrete thesis: &lt;strong&gt;this change benefits almost exclusively OpenAI, marginally benefits competing hyperscalers, and for the independent dev calling the API directly it's noise — except for one pricing variable that's actually worth understanding.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This isn't a hot take. It's what I read in my own numbers.&lt;/p&gt;

&lt;h2&gt;
  
  
  What My Last 90 Days of Logs Say
&lt;/h2&gt;

&lt;p&gt;I run my projects on Railway. I call the OpenAI API directly — I never went through Azure OpenAI Service because the setup overhead never made sense for small projects. When I looked at my logs from the last 90 days, the pattern was clear:&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;# Extract calls by model and cost - last 90 days&lt;/span&gt;
&lt;span class="c"&gt;# File: analyze_api_logs.sh&lt;/span&gt;

&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;
&lt;span class="nv"&gt;LOGS_DIR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"./logs/api"&lt;/span&gt;

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"=== Call distribution by model ==="&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s1"&gt;'"model"'&lt;/span&gt; &lt;span class="nv"&gt;$LOGS_DIR&lt;/span&gt;/&lt;span class="k"&gt;*&lt;/span&gt;.jsonl | &lt;span class="se"&gt;\&lt;/span&gt;
  jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.model'&lt;/span&gt; | &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nb"&gt;sort&lt;/span&gt; | &lt;span class="nb"&gt;uniq&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt;

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"=== Estimated cost by model (USD) ==="&lt;/span&gt;
&lt;span class="c"&gt;# Each line has: timestamp, model, input_tokens, output_tokens, cost_usd&lt;/span&gt;
&lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="nt"&gt;-F&lt;/span&gt;&lt;span class="s1"&gt;','&lt;/span&gt; &lt;span class="s1"&gt;'
  NR&amp;gt;1 {
    model[$2] += $5
    calls[$2]++
  }
  END {
    for (m in model)
      printf "%-25s calls: %d  total: $%.4f\n", m, calls[m], model[m]
  }
'&lt;/span&gt; &lt;span class="nv"&gt;$LOGS_DIR&lt;/span&gt;/summary.csv | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-t&lt;/span&gt;&lt;span class="s1"&gt;'$'&lt;/span&gt; &lt;span class="nt"&gt;-k2&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Actual output from my last 90 days:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;=== Call distribution by model ===
   4821 gpt-4o
   2103 gpt-4o-mini
    847 o1-mini
    312 gpt-4-turbo (legacy, migrating)

=== Estimated cost by model (USD) ===
gpt-4o                    calls: 4821  total: $38.4200
o1-mini                   calls: 847   total: $14.9300
gpt-4o-mini               calls: 2103  total: $2.1800
gpt-4-turbo               calls: 312   total: $4.8800
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Total over 90 days: &lt;strong&gt;~$60.40 USD calling api.openai.com directly.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Would I have paid something different using Azure OpenAI Service? Yes. Azure charges a markup on the same calls — historically between 10% and 20% depending on tier and region. On $60 over 90 days, that's between $6 and $12 of difference. Nothing. For a company spending $60,000 in 90 days, that's between $6,000 and $12,000. That's where the real game is.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Change Benefits OpenAI More Than Microsoft
&lt;/h2&gt;

&lt;p&gt;The original deal was brilliant for Microsoft in 2019: OpenAI needed compute and money, Microsoft needed AI credibility. But the world changed. Today OpenAI has:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Direct revenue&lt;/strong&gt; from subscriptions (ChatGPT Plus, Team, Enterprise)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Its own API&lt;/strong&gt; with millions of devs calling it directly&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Negotiating leverage&lt;/strong&gt; that simply didn't exist in 2019&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Distribution exclusivity gave Microsoft a privileged channel to enterprise customers. But that channel has a cost: every deal Microsoft closed with a big client via Azure OpenAI Service meant OpenAI was seeing a fraction of the revenue it would've captured on its own.&lt;/p&gt;

&lt;p&gt;Breaking exclusivity means OpenAI can now negotiate direct deals with Google Cloud, with AWS, with any enterprise consultancy that wants to offer its models. More channels, more revenue, more control.&lt;/p&gt;

&lt;p&gt;For Microsoft, the cost is real but bounded: Azure is still the cloud provider with the deepest integration, with Copilot, with the M365 ecosystem. They don't lose everything. But they lose the advantage of being the only authorized enterprise provider.&lt;/p&gt;

&lt;p&gt;The uncomfortable thing nobody says in the HN threads: &lt;strong&gt;Microsoft knew this was coming.&lt;/strong&gt; OpenAI's current valuation makes it indefensible to charge a markup for exclusive access when the product owner can walk and sell directly. The end of the exclusive was negotiated, not ripped away.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Invoice Detail That Actually Matters for Independent Devs
&lt;/h2&gt;

&lt;p&gt;I promised there was an exception. Here it is.&lt;/p&gt;

&lt;p&gt;When I reviewed my March invoice I found something: I started receiving usage credits through an Azure program I have active through my dev subscription. Credits that &lt;strong&gt;only apply if you call OpenAI via Azure OpenAI Service&lt;/strong&gt;, not via the direct API.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Endpoint comparison - same call, different billing&lt;/span&gt;
&lt;span class="c1"&gt;// api.openai.com = direct billing, no Azure credits&lt;/span&gt;
&lt;span class="c1"&gt;// azure.openai.com = Azure Dev/Startup credits apply if you have them&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;OPENAI_DIRECT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://api.openai.com/v1/chat/completions&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;// No Azure credits&lt;/span&gt;
  &lt;span class="c1"&gt;// Lower latency in some cases (no extra hop)&lt;/span&gt;
  &lt;span class="c1"&gt;// Setup: 2 minutes&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;AZURE_OPENAI&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`https://&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;AZURE_RESOURCE&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.openai.azure.com/openai/deployments/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;DEPLOYMENT&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/chat/completions`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;// Azure credits apply if you have them active&lt;/span&gt;
  &lt;span class="c1"&gt;// Easier enterprise compliance (data residency, VNet, etc.)&lt;/span&gt;
  &lt;span class="c1"&gt;// Setup: 20 minutes minimum, more if you use managed identity&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you have active Azure credits — startup programs, Visual Studio subscriptions, Microsoft for Startups — and you're calling OpenAI directly, you're leaving money on the table. That doesn't change with the new agreement, but it is something that could get renegotiated now that the exclusive is broken: if Google Cloud or AWS start offering similar credits for OpenAI model access, the incentive ecosystem opens up.&lt;/p&gt;

&lt;p&gt;For now, in my specific case: I evaluated moving $30–$40 per month to Azure for the credits. The setup overhead stopped me. I'm staying on direct.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Most Common Misreads I Saw in the HN Threads
&lt;/h2&gt;

&lt;p&gt;The 880-point thread has some error patterns worth dismantling:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Now OpenAI can go with Google Cloud and everyone migrates"&lt;/strong&gt; — Not so fast. Microsoft has compute agreements that go well beyond the distribution deal. OpenAI runs on Azure infrastructure. That doesn't change overnight.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Microsoft lost its $13B bet"&lt;/strong&gt; — The $13B wasn't a payment for exclusivity; it was structured investment in a company now worth exponentially more. The investment is still an investment.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Devs will get cheaper prices now"&lt;/strong&gt; — Why? OpenAI's API has its own pricing. Azure was charging a markup on top of that. Without the exclusive, Azure could lower its markup to stay competitive, but nothing guarantees it happens tomorrow. Competition takes time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"This is the beginning of the end for Azure"&lt;/strong&gt; — Azure has 200+ services. OpenAI is one. Anyone saying this is confusing media visibility with actual business weight.&lt;/p&gt;




&lt;p&gt;On the architecture topics I've been working through this week — the &lt;a href="https://juanchi.dev/en/blog/ai-agent-deleted-production-database-logs-guardrails-real-analysis" rel="noopener noreferrer"&gt;agent that deleted my production database&lt;/a&gt;, the &lt;a href="https://juanchi.dev/en/blog/plain-text-won-migrating-notion-to-markdown-what-i-lost" rel="noopener noreferrer"&gt;Notion to Markdown migration&lt;/a&gt;, the &lt;a href="https://juanchi.dev/en/blog/godaddy-domain-hijacking-simulated-attack-own-infra" rel="noopener noreferrer"&gt;supply chain issues I dug into with Bitwarden&lt;/a&gt; — there's a pattern that keeps repeating: the changes that hit hardest aren't the ones with 880 upvotes on HN. They're the quiet ones. The breaking of the exclusivity deal is loud. What actually changes my real workflow is somewhere else.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ: Microsoft and OpenAI Exclusivity Deal
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What exactly was the exclusivity agreement between Microsoft and OpenAI?&lt;/strong&gt;&lt;br&gt;
Microsoft had exclusive rights to distribute and commercialize OpenAI's models through its Azure cloud platform. Any company that wanted to integrate GPT-4 or similar models into enterprise products had to go through Azure OpenAI Service. That gave Microsoft a commercial markup and a privileged position over Google Cloud, AWS, and others.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What changes for an independent developer already using the OpenAI API directly?&lt;/strong&gt;&lt;br&gt;
Almost nothing in the short term. The prices at api.openai.com don't change because of this announcement. The possible positive consequence in the medium term is that more competition between cloud providers could push prices down or generate more accessible credit programs. For now, if you're not using Azure, the only change is strategic context.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Should I migrate to Azure OpenAI Service after this change?&lt;/strong&gt;&lt;br&gt;
Depends on whether you have active Azure credits. If you use Visual Studio Enterprise, Microsoft for Startups, or other programs with credits, it's worth running the numbers. If you're paying Azure at list price without credits, the historical markup means the direct API is still cheaper for low to medium volumes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can OpenAI now run its models on Google Cloud or AWS?&lt;/strong&gt;&lt;br&gt;
Technically yes, the distribution agreement no longer blocks it. But OpenAI has all its training and inference infrastructure on Azure. Moving that is a years-long decision, not months. What can happen is that Google Cloud or AWS offer access to OpenAI models as resellers, similar to how other cloud marketplace agreements work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does this affect the pricing of Copilot or Microsoft's AI-integrated products?&lt;/strong&gt;&lt;br&gt;
Not directly. Copilot products (M365, GitHub, Azure) have their own agreements and pricing structures that don't depend on the API exclusivity deal. Microsoft still has access to OpenAI's models; what it loses is the monopoly on who else can have it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I know if I should switch endpoints in my current projects?&lt;/strong&gt;&lt;br&gt;
Open your logs from the last 30–60 days, calculate what you're spending on api.openai.com, and check if you have available Azure credits. If your monthly spend is above $100 USD and you have unused credits, the Azure OpenAI Service setup pays for itself in a few weeks. Below that, the configuration overhead doesn't pencil out.&lt;/p&gt;




&lt;h2&gt;
  
  
  My Final Take, Unfiltered
&lt;/h2&gt;

&lt;p&gt;The end of the exclusivity agreement is an important corporate news story. For the industry, it's a signal of maturity: OpenAI no longer needs Microsoft's umbrella to reach enterprise. For Microsoft, it's a calculated concession that keeps the investment intact while releasing regulatory pressure.&lt;/p&gt;

&lt;p&gt;For me, looking at my $60 in 90 days of logs: irrelevant — unless someone activates a credits program that justifies switching endpoints.&lt;/p&gt;

&lt;p&gt;What I do find relevant — and this is something I didn't read in any of the 400+ comments in that thread — is that this move opens the door for OpenAI to build direct deals with companies that until now had to negotiate through Microsoft. That concentrates more power in OpenAI, not less. And a company with that level of centralized power over models already running in critical production systems — including mine, including almost everyone who read that thread — deserves more scrutiny than it gets when the headlines are talking about "competition" and "openness."&lt;/p&gt;

&lt;p&gt;The openness that matters isn't between cloud providers. It's between models, between vendors, between architectures. The fact that a dev today can choose between GPT-4o, Claude, Gemini, and Mistral at comparable costs — that's actual openness. The rest is just reorganizing who collects the markup.&lt;/p&gt;

&lt;p&gt;If you're interested in multi-provider architecture decisions, last week I wrote about &lt;a href="https://juanchi.dev/en/blog/typescript-7-beta-real-codebase-results-what-changed" rel="noopener noreferrer"&gt;TypeScript 7.0 Beta on a real codebase&lt;/a&gt; — there's an abstraction pattern for clients that applies directly to this. And if you want to see how I think about owning my infra before trusting third-party services with critical decisions, start with the &lt;a href="https://juanchi.dev/en/blog/asahi-linux-70-apple-silicon-installed-measured-real-workflow" rel="noopener noreferrer"&gt;Asahi Linux on Apple Silicon post&lt;/a&gt;: the philosophy is the same.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This article was originally published on &lt;a href="https://juanchi.dev/en/blog/microsoft-openai-exclusive-deal-api-logs-who-benefits" rel="noopener noreferrer"&gt;juanchi.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>english</category>
      <category>ia</category>
      <category>openai</category>
      <category>logs</category>
    </item>
    <item>
      <title>Microsoft y OpenAI rompen su acuerdo exclusivo: lo que mis logs de uso dicen sobre a quién le conviene realmente</title>
      <dc:creator>Juan Torchia</dc:creator>
      <pubDate>Tue, 28 Apr 2026 12:31:17 +0000</pubDate>
      <link>https://forem.com/jtorchia/microsoft-y-openai-rompen-su-acuerdo-exclusivo-lo-que-mis-logs-de-uso-dicen-sobre-a-quien-le-efh</link>
      <guid>https://forem.com/jtorchia/microsoft-y-openai-rompen-su-acuerdo-exclusivo-lo-que-mis-logs-de-uso-dicen-sobre-a-quien-le-efh</guid>
      <description>&lt;h1&gt;
  
  
  Microsoft y OpenAI rompen su acuerdo exclusivo: lo que mis logs de uso dicen sobre a quién le conviene realmente
&lt;/h1&gt;

&lt;p&gt;En 2009, a los 18 años, estudiaba para el CCNA de noche después de ocho horas en el cyber. Cisco tenía un ecosistema casi monopólico en networking empresarial en Argentina. Recuerdo haber pensado: "si Cisco rompe algo con algún partner, ¿a mí qué me cambia? Igual tengo que saber OSPF". Hoy, leyendo que Microsoft y OpenAI disolvieron su acuerdo de exclusividad —el notición que dominó Hacker News con 880 puntos en pocas horas— me vino exactamente la misma sensación. Mucho ruido arriba. Abajo, en mis logs, la historia es más aburrida y más honesta.&lt;/p&gt;

&lt;p&gt;Pero hay una excepción. Y la encontré en mi factura de marzo.&lt;/p&gt;

&lt;h2&gt;
  
  
  Microsoft OpenAI deal exclusividad: qué cambió y qué no
&lt;/h2&gt;

&lt;p&gt;Para el lector que ya vino con el contexto de la semana: no voy a repetir la historia del deal de 2019, los 13 mil millones de dólares o los derechos de distribución. Ya está. Lo nuevo es que Microsoft ya no tiene exclusividad sobre la API de OpenAI para cloud providers. Cualquier otro proveedor —Google Cloud, AWS, Oracle— puede ahora ofrecer acceso directo a GPT-4o, o1, o lo que venga después, sin pasar por Azure OpenAI Service.&lt;/p&gt;

&lt;p&gt;Mi tesis concreta: &lt;strong&gt;este cambio beneficia casi exclusivamente a OpenAI, marginalmente a los hiperescalares competidores, y para el dev independiente que llama la API directamente es ruido salvo por una variable de pricing que vale la pena entender.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No es una opinión caliente. Es lo que leo en mis propios números.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lo que dicen mis logs de los últimos 90 días
&lt;/h2&gt;

&lt;p&gt;Corro mis proyectos en Railway. Llamo a la API de OpenAI directo —nunca pasé por Azure OpenAI Service porque el overhead de setup no me cerró para proyectos pequeños. Cuando miré mis logs de los últimos 90 días, el patrón fue claro:&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;# Extraer llamadas por modelo y costo - últimos 90 días&lt;/span&gt;
&lt;span class="c"&gt;# Archivo: analyze_api_logs.sh&lt;/span&gt;

&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;
&lt;span class="nv"&gt;LOGS_DIR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"./logs/api"&lt;/span&gt;

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"=== Distribución de llamadas por modelo ==="&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s1"&gt;'"model"'&lt;/span&gt; &lt;span class="nv"&gt;$LOGS_DIR&lt;/span&gt;/&lt;span class="k"&gt;*&lt;/span&gt;.jsonl | &lt;span class="se"&gt;\&lt;/span&gt;
  jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.model'&lt;/span&gt; | &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nb"&gt;sort&lt;/span&gt; | &lt;span class="nb"&gt;uniq&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt;

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"=== Costo estimado por modelo (USD) ==="&lt;/span&gt;
&lt;span class="c"&gt;# Cada línea tiene: timestamp, model, input_tokens, output_tokens, cost_usd&lt;/span&gt;
&lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="nt"&gt;-F&lt;/span&gt;&lt;span class="s1"&gt;','&lt;/span&gt; &lt;span class="s1"&gt;'
  NR&amp;gt;1 {
    modelo[$2] += $5
    llamadas[$2]++
  }
  END {
    for (m in modelo)
      printf "%-25s llamadas: %d  total: $%.4f\n", m, llamadas[m], modelo[m]
  }
'&lt;/span&gt; &lt;span class="nv"&gt;$LOGS_DIR&lt;/span&gt;/summary.csv | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-t&lt;/span&gt;&lt;span class="s1"&gt;'$'&lt;/span&gt; &lt;span class="nt"&gt;-k2&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Resultado real de mis últimos 90 días:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;=== Distribución de llamadas por modelo ===
   4821 gpt-4o
   2103 gpt-4o-mini
    847 o1-mini
    312 gpt-4-turbo (legacy, migrando)

=== Costo estimado por modelo (USD) ===
gpt-4o                    llamadas: 4821  total: $38.4200
o1-mini                   llamadas: 847   total: $14.9300
gpt-4o-mini               llamadas: 2103  total: $2.1800
gpt-4-turbo               llamadas: 312   total: $4.8800
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Total en 90 días: &lt;strong&gt;~$60.40 USD llamando directo a api.openai.com&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;¿Habría pagado algo diferente usando Azure OpenAI Service? Sí. Azure cobra un markup sobre las mismas llamadas —históricamente entre 10% y 20% dependiendo del tier y región. Para $60 en 90 días eso son entre $6 y $12 de diferencia. No es nada. Para una empresa que gasta $60.000 en 90 días, son entre $6.000 y $12.000. Ahí está el juego real.&lt;/p&gt;

&lt;h2&gt;
  
  
  Por qué este cambio beneficia más a OpenAI que a Microsoft
&lt;/h2&gt;

&lt;p&gt;El acuerdo original fue brillante para Microsoft en 2019: OpenAI necesitaba compute y dinero, Microsoft necesitaba credibilidad en IA. Pero el mundo cambió. OpenAI hoy tiene:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Ingresos directos&lt;/strong&gt; por subscripciones (ChatGPT Plus, Team, Enterprise)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;API propia&lt;/strong&gt; con millones de devs que llaman directo&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Capacidad negociadora&lt;/strong&gt; que en 2019 simplemente no existía&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;La exclusividad de distribución le daba a Microsoft un canal privilegiado hacia clientes enterprise. Pero ese canal tiene un costo: cada deal que Microsoft cerraba con un cliente grande vía Azure OpenAI Service, OpenAI veía una fracción del ingreso que habría capturado sola.&lt;/p&gt;

&lt;p&gt;Romper la exclusividad significa que OpenAI puede ahora negociar deals directos con Google Cloud, con AWS, con cualquier consultora enterprise que quiera ofrecer sus modelos. Más canales, más ingresos, más control.&lt;/p&gt;

&lt;p&gt;Para Microsoft, el costo es real pero acotado: Azure sigue siendo el cloud provider con la integración más profunda, con Copilot, con el ecosistema M365. No pierden todo. Pero pierden la ventaja de ser el único proveedor enterprise autorizado.&lt;/p&gt;

&lt;p&gt;Lo incómodo que nadie dice en los threads de HN: &lt;strong&gt;Microsoft sabía que esto llegaba&lt;/strong&gt;. La valuación actual de OpenAI hace indefendible cobrar un markup por acceso exclusivo cuando el dueño del producto puede irse a vender directo. El fin del exclusivo fue negociado, no arrancado.&lt;/p&gt;

&lt;h2&gt;
  
  
  El dato en la factura que sí importa para devs independientes
&lt;/h2&gt;

&lt;p&gt;Prometí que había una excepción. Acá está.&lt;/p&gt;

&lt;p&gt;Cuando revisé mi factura de marzo encontré algo: empecé a recibir créditos de uso a través de un programa de Azure que tengo activo por mi suscripción de dev. Créditos que &lt;strong&gt;solo aplican si llamás a OpenAI vía Azure OpenAI Service&lt;/strong&gt;, no vía la API directa.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Comparación de endpoints - misma llamada, diferente billing&lt;/span&gt;
&lt;span class="c1"&gt;// api.openai.com = facturación directa, sin créditos Azure&lt;/span&gt;
&lt;span class="c1"&gt;// azure.openai.com = aplican créditos Azure Dev/Startup si los tenés&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;OPENAI_DIRECT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://api.openai.com/v1/chat/completions&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;// Sin créditos Azure&lt;/span&gt;
  &lt;span class="c1"&gt;// Menor latencia en algunos casos (sin hop extra)&lt;/span&gt;
  &lt;span class="c1"&gt;// Setup: 2 minutos&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;AZURE_OPENAI&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`https://&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;AZURE_RESOURCE&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.openai.azure.com/openai/deployments/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;DEPLOYMENT&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/chat/completions`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;// Aplican créditos Azure si los tenés activos&lt;/span&gt;
  &lt;span class="c1"&gt;// Compliance enterprise más fácil (data residency, VNet, etc.)&lt;/span&gt;
  &lt;span class="c1"&gt;// Setup: 20 minutos mínimo, más si usás managed identity&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Si tenés créditos Azure activos —startup programs, Visual Studio subscriptions, Microsoft for Startups— y estás llamando a OpenAI directo, estás dejando plata sobre la mesa. Eso no cambia con el nuevo acuerdo, pero sí es algo que con el exclusivo roto podría renegociarse: si Google Cloud o AWS empiezan a ofrecer créditos similares para acceso a modelos OpenAI, el ecosistema de incentivos se abre.&lt;/p&gt;

&lt;p&gt;Por ahora, en mi caso concreto: evalué mover $30-$40 por mes a Azure por los créditos. El overhead de setup me frenó. Sigo en directo.&lt;/p&gt;

&lt;h2&gt;
  
  
  Los errores de lectura más comunes que vi en los threads de HN
&lt;/h2&gt;

&lt;p&gt;El thread de 880 puntos tiene algunos patrones de error que vale la pena desmontar:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Ahora OpenAI puede irse con Google Cloud y todos migran"&lt;/strong&gt; — No tan rápido. Microsoft tiene acuerdos de compute que van más allá del deal de distribución. OpenAI corre en infraestructura Azure. Eso no cambia de un día para el otro.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Microsoft perdió su apuesta de $13B"&lt;/strong&gt; — Los $13B no eran un pago por exclusividad; eran inversión estructurada en una empresa que hoy vale exponencialmente más. La inversión sigue siendo inversión.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Para los devs ahora es más barato"&lt;/strong&gt; — ¿Por qué? La API de OpenAI tiene sus precios. Azure cobraba markup sobre eso. Sin exclusivo, Azure puede bajar el markup para ser competitivo, pero nada garantiza que lo haga mañana. La competencia tarda en llegar.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Esto es el inicio del fin de Azure"&lt;/strong&gt; — Azure tiene 200+ servicios. OpenAI es uno. Quien dice esto confunde visibilidad mediática con peso de negocio.&lt;/p&gt;




&lt;p&gt;Sobre los temas de arquitectura que vengo trabajando esta semana —el &lt;a href="https://juanchi.dev/es/blog/agente-ia-borro-base-datos-produccion-logs-guardrails" rel="noopener noreferrer"&gt;agente que borró mi base de datos en producción&lt;/a&gt;, la migración de &lt;a href="https://juanchi.dev/es/blog/migrar-notion-markdown-plain-text-lo-que-perdi" rel="noopener noreferrer"&gt;Notion a Markdown&lt;/a&gt;, los &lt;a href="https://juanchi.dev/es/blog/godaddy-domain-hijacking-security-simulacion-ataque-infra-propia" rel="noopener noreferrer"&gt;problemas de supply chain que revisé con Bitwarden&lt;/a&gt;— hay un patrón que se repite: los cambios que más impactan no son los que tienen 880 upvotes en HN. Son los silenciosos. La rotura del deal exclusivo es ruidosa. Lo que cambia mi flujo de trabajo real está en otra parte.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ: Microsoft OpenAI deal exclusividad
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;¿Qué era exactamente el acuerdo de exclusividad entre Microsoft y OpenAI?&lt;/strong&gt;&lt;br&gt;
Microsoft tenía derechos exclusivos para distribuir y comercializar los modelos de OpenAI a través de su plataforma cloud Azure. Cualquier empresa que quisiera integrar GPT-4 o modelos similares en productos enterprise necesitaba pasar por Azure OpenAI Service. Eso le daba a Microsoft un markup comercial y una posición privilegiada frente a Google Cloud, AWS y otros.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;¿Qué cambia para un desarrollador independiente que ya usa la API de OpenAI directa?&lt;/strong&gt;&lt;br&gt;
Casi nada en el corto plazo. Los precios de api.openai.com no cambian por este anuncio. La posible consecuencia positiva a mediano plazo es que más competencia entre cloud providers podría bajar precios o generar programas de créditos más accesibles. Por ahora, si no usás Azure, el único cambio es de contexto estratégico.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;¿Conviene migrar a Azure OpenAI Service después de este cambio?&lt;/strong&gt;&lt;br&gt;
Depende de si tenés créditos Azure activos. Si usás Visual Studio Enterprise, Microsoft for Startups u otros programas con créditos, vale hacer el análisis. Si pagás Azure a precio de lista sin créditos, el markup histórico hace que la API directa siga siendo más barata para volúmenes bajos o medianos.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;¿OpenAI puede ahora correr sus modelos en Google Cloud o AWS?&lt;/strong&gt;&lt;br&gt;
Técnicamente sí, el acuerdo de distribución ya no lo impide. Pero OpenAI tiene toda su infraestructura de entrenamiento e inferencia en Azure. Mover eso es una decisión de años, no de meses. Lo que sí puede pasar es que Google Cloud o AWS ofrezcan acceso a los modelos de OpenAI como resellers, similar a cómo funcionan otros acuerdos de marketplace en la nube.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;¿Esto afecta el pricing de Copilot o los productos Microsoft con IA integrada?&lt;/strong&gt;&lt;br&gt;
No directamente. Los productos Copilot (M365, GitHub, Azure) tienen sus propios acuerdos y estructuras de precios que no dependen del deal de exclusividad API. Microsoft sigue teniendo acceso a los modelos de OpenAI; lo que pierde es el monopolio sobre quién más puede tenerlo.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;¿Cómo puedo saber si me conviene cambiar de endpoint en mis proyectos actuales?&lt;/strong&gt;&lt;br&gt;
Abrí tus logs de los últimos 30-60 días, calculá cuánto gastás en api.openai.com, y verificá si tenés créditos Azure disponibles. Si el gasto mensual supera los $100 USD y tenés créditos sin usar, el setup de Azure OpenAI Service se amortiza en pocas semanas. Por debajo de eso, el overhead de configuración no cierra.&lt;/p&gt;




&lt;h2&gt;
  
  
  Mi postura final, sin suavizar
&lt;/h2&gt;

&lt;p&gt;El fin del acuerdo exclusivo es una noticia corporativa importante. Para la industria, es señal de madurez: OpenAI ya no necesita el paraguas de Microsoft para llegar a enterprise. Para Microsoft, es una concesión calculada que mantiene la inversión intacta mientras libera presión regulatoria.&lt;/p&gt;

&lt;p&gt;Para mí, mirando mis $60 en 90 días de logs: irrelevante salvo que alguien active un programa de créditos que justifique el cambio de endpoint.&lt;/p&gt;

&lt;p&gt;Lo que sí me parece relevante —y esto es lo que no leí en ninguno de los 400+ comentarios del thread— es que este movimiento le abre la puerta a OpenAI para construir deals directos con empresas que hasta ahora tenían que negociar a través de Microsoft. Eso concentra más poder en OpenAI, no menos. Y una empresa con ese nivel de poder centralizado sobre modelos que ya corren en producciones críticas —incluyendo la mía, incluyendo la de casi todos los que leyeron ese thread— merece más escrutinio del que recibe cuando los titulares hablan de "competencia" y "apertura".&lt;/p&gt;

&lt;p&gt;La apertura que importa no es entre cloud providers. Es entre modelos, entre proveedores, entre arquitecturas. Que un dev pueda hoy elegir entre GPT-4o, Claude, Gemini y Mistral con costos comparables —eso sí es apertura. El resto es reorganización de quién cobra el markup.&lt;/p&gt;

&lt;p&gt;Si te interesa el tema de arquitectura de decisiones con múltiples providers, la semana pasada escribí sobre &lt;a href="https://juanchi.dev/es/blog/typescript-70-beta-novedades-prueba-codebase-real" rel="noopener noreferrer"&gt;TypeScript 7.0 Beta en codebase real&lt;/a&gt; —hay un patrón de abstracción de cliente que aplica directo a esto. Y si querés ver cómo pienso la infra propia antes de confiarle decisiones a servicios de terceros, empezá por el post de &lt;a href="https://juanchi.dev/es/blog/asahi-linux-70-apple-silicon-instalacion-kernel-arm" rel="noopener noreferrer"&gt;Asahi Linux en Apple Silicon&lt;/a&gt;: la filosofía es la misma.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Este artículo fue publicado originalmente en &lt;a href="https://juanchi.dev/es/blog/microsoft-openai-deal-exclusividad-logs-uso-costos-api" rel="noopener noreferrer"&gt;juanchi.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>spanish</category>
      <category>espanol</category>
      <category>ia</category>
      <category>openai</category>
    </item>
    <item>
      <title>GoDaddy gave my domain to a stranger: I simulated the attack on my own infra and learned how exposed I really was</title>
      <dc:creator>Juan Torchia</dc:creator>
      <pubDate>Mon, 27 Apr 2026 16:32:42 +0000</pubDate>
      <link>https://forem.com/jtorchia/godaddy-gave-my-domain-to-a-stranger-i-simulated-the-attack-on-my-own-infra-and-learned-how-3g0i</link>
      <guid>https://forem.com/jtorchia/godaddy-gave-my-domain-to-a-stranger-i-simulated-the-attack-on-my-own-infra-and-learned-how-3g0i</guid>
      <description>&lt;h1&gt;
  
  
  GoDaddy gave my domain to a stranger: I simulated the attack on my own infra and learned how exposed I really was
&lt;/h1&gt;

&lt;p&gt;It was 10pm and I was scrolling Hacker News when the post hit the top: GoDaddy had transferred a domain to someone who wasn't the owner. Score 610, 200+ comments, most of them saying "I moved to Cloudflare years ago" or "this is why you don't use GoDaddy". I closed the tab. And then I just sat there thinking.&lt;/p&gt;

&lt;p&gt;I have six active domains. Three on Namecheap, two on Cloudflare Registrar, one on Porkbun. All connected to Railway or Vercel. All pointing at things that, if they go down, come crashing down on me.&lt;/p&gt;

&lt;p&gt;My first reaction was the condescending nerd one: "I don't use GoDaddy, I'm fine." My second reaction, twenty minutes later, was the architect who knows that arrogance in security is the most expensive vulnerability. So I didn't cover the incident as news. I used it as an excuse to simulate the attack against my own infrastructure, document every step, and measure how far an attacker would have gotten before I even noticed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;My thesis, before diving in:&lt;/strong&gt; the problem isn't GoDaddy specifically. It's that the DNS identity verification chain was designed in the '90s for a world where changing a record was a rare, manual event. Today, with automation, CI/CD, and registrar APIs that respond in milliseconds, that chain is wet paper. And nobody's redesigning it.&lt;/p&gt;




&lt;h2&gt;
  
  
  GoDaddy domain hijacking: what happened and why I took it personally
&lt;/h2&gt;

&lt;p&gt;The case reported on HN described a classic social engineering vector combined with a support process that validated identity by email. The attacker didn't hack any system. They called (or wrote), presented fake documentation, and GoDaddy's internal process handled the transfer.&lt;/p&gt;

&lt;p&gt;That's when I actually got angry. Because it's not a bug. It's a process working exactly as designed — and the design is the problem.&lt;/p&gt;

&lt;p&gt;I went and reviewed the transfer processes for the three registrars I use. Here's what I found for an outbound domain transfer:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Namecheap&lt;/strong&gt;: confirmation email to the registered address + authorization code (EPP). If the attacker has access to the email, the domain walks out in 5-7 days with zero additional friction.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloudflare Registrar&lt;/strong&gt;: same as Namecheap, plus optional 2FA (which I had enabled on two of three domains, not all — embarrassing detail).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Porkbun&lt;/strong&gt;: email + mandatory TOTP for transfers. The most resistant of the three.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The attack vector isn't the registrar. It's the email address associated with the registrar account.&lt;/p&gt;




&lt;h2&gt;
  
  
  How I simulated the attack: step by step with my own stack
&lt;/h2&gt;

&lt;p&gt;I didn't break anything real. I used a test domain I have on Namecheap (&lt;code&gt;juanchi-test-[hash].com&lt;/code&gt;, bought in January, never pointed at production) and documented every step as if I were an attacker with access to my Google Workspace email.&lt;/p&gt;

&lt;h3&gt;
  
  
  Phase 1: reconnaissance
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# First thing an attacker would do: map the surface&lt;/span&gt;
whois juanchi-test-[hash].com

&lt;span class="c"&gt;# Relevant output (anonymized):&lt;/span&gt;
&lt;span class="c"&gt;# Registrar: Namecheap, Inc.&lt;/span&gt;
&lt;span class="c"&gt;# Creation Date: 2025-01-15&lt;/span&gt;
&lt;span class="c"&gt;# Registry Expiry Date: 2026-01-15&lt;/span&gt;
&lt;span class="c"&gt;# Name Server: dns1.registrar-servers.com&lt;/span&gt;
&lt;span class="c"&gt;# Name Server: dns2.registrar-servers.com&lt;/span&gt;
&lt;span class="c"&gt;# DNSSEC: unsigned  &amp;lt;-- this matters, we'll get to it&lt;/span&gt;

&lt;span class="c"&gt;# Registrant email was redacted (WhoisGuard active)&lt;/span&gt;
&lt;span class="c"&gt;# But the registrar shows up — first useful data point for the attacker&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;WHOIS gave me the registrar. With that, an attacker knows who to call. WhoisGuard hides the technical contact's email, but doesn't hide the registrar. It's like hiding your name on the door but leaving the apartment number visible.&lt;/p&gt;

&lt;h3&gt;
  
  
  Phase 2: the real vector — compromise the email first
&lt;/h3&gt;

&lt;p&gt;Here's the insight that made me most uncomfortable. An attack on a domain doesn't start at the registrar. It starts at the email.&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;# Checked what MX records my test domain had&lt;/span&gt;
dig MX juanchi-test-[hash].com

&lt;span class="c"&gt;# And what SPF/DKIM the email domain I use for the registrar had&lt;/span&gt;
dig TXT juanchi-dev.com | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s2"&gt;"v=spf|DKIM"&lt;/span&gt;

&lt;span class="c"&gt;# Result: SPF configured, DKIM active on Cloudflare&lt;/span&gt;
&lt;span class="c"&gt;# But the weak point isn't the configuration — it's account recovery&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Google Workspace has SMS recovery. My phone number is in the profile. If someone does SIM swapping, they have my email. If they have my email, they have all my Namecheap domains and two of three on Cloudflare (the one without 2FA enabled).&lt;/p&gt;

&lt;p&gt;That was the moment I understood I was just as vulnerable as any GoDaddy user. My attacker just needed one extra step first.&lt;/p&gt;

&lt;h3&gt;
  
  
  Phase 3: what Namecheap "support" requires for an emergency transfer
&lt;/h3&gt;

&lt;p&gt;I didn't call pretending to be anyone. I read Namecheap's public support documentation for domain recovery cases when you've "lost access to your email." Here's what they ask for:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Photo of your ID document&lt;/li&gt;
&lt;li&gt;Last purchase invoice for the domain&lt;/li&gt;
&lt;li&gt;Description of the problem&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's it. No liveness check. No callback to the registered phone number. No independent second factor. If an attacker has a photo of my ID (which exists on LinkedIn, at conferences, in a million places), a convincing fake invoice, and email access — or doesn't need the email because support can override — the domain can walk.&lt;/p&gt;

&lt;p&gt;This process exists at &lt;strong&gt;almost every registrar&lt;/strong&gt;. Because it was designed for the legitimate case of "I forgot my password and changed my email." Not for a sophisticated attacker.&lt;/p&gt;

&lt;h3&gt;
  
  
  Phase 4: what would happen next — the damage to my Railway/Vercel stack
&lt;/h3&gt;

&lt;p&gt;Once the domain is transferred, the attacker controls DNS. Here's what they could do against my Railway and Vercel infra:&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;# Scenario: attacker has domain juanchi.dev&lt;/span&gt;
&lt;span class="c"&gt;# Step 1: changes the A record to their own server&lt;/span&gt;
&lt;span class="c"&gt;# Time for the change to propagate: 30 min to 48h depending on TTL&lt;/span&gt;

&lt;span class="c"&gt;# My current TTLs (measured before the experiment):&lt;/span&gt;
dig juanchi.dev | &lt;span class="nb"&gt;grep &lt;/span&gt;TTL
&lt;span class="c"&gt;# TTL: 300 seconds — propagation in 5 minutes&lt;/span&gt;

&lt;span class="c"&gt;# Step 2: request SSL certificate with Let's Encrypt&lt;/span&gt;
&lt;span class="c"&gt;# ACME HTTP-01 or DNS-01 challenge — trivial with domain control&lt;/span&gt;
&lt;span class="c"&gt;# Time: under 2 minutes&lt;/span&gt;

&lt;span class="c"&gt;# Step 3: mount a reverse proxy pointing at my Railway deployment&lt;/span&gt;
&lt;span class="c"&gt;# Or just an identical phishing page&lt;/span&gt;
&lt;span class="c"&gt;# With a valid cert and the real domain, the browser shows nothing unusual&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The 300-second TTL I had configured for performance would have given me a five-minute detection window after the change. Way too short.&lt;/p&gt;

&lt;p&gt;This connects directly to what I documented in the &lt;a href="https://juanchi.dev/en/blog/typescript-7-beta-real-codebase-results-what-changed" rel="noopener noreferrer"&gt;Vercel breach post&lt;/a&gt; — when the deployment infrastructure is compromised, or the domain feeding it is compromised, the damage isn't technical. It's trust. And trust doesn't roll back.&lt;/p&gt;




&lt;h2&gt;
  
  
  The mistakes I found in my own configuration
&lt;/h2&gt;

&lt;p&gt;I'm going to be specific because that's the most valuable part of this experiment. These are the real problems I had before simulating the attack:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mistake 1: inconsistent 2FA across registrars&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;One domain on Cloudflare Registrar without 2FA enabled. No excuse. Fixed it in ten minutes during the experiment.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mistake 2: DNSSEC disabled on all my domains&lt;/strong&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;# Quick DNSSEC check&lt;/span&gt;
dig DS juanchi.dev @8.8.8.8
&lt;span class="c"&gt;# Empty response = DNSSEC not configured&lt;/span&gt;

&lt;span class="c"&gt;# With DNSSEC active, an attacker who modifies DNS records&lt;/span&gt;
&lt;span class="c"&gt;# generates responses that resolvers validate and reject&lt;/span&gt;
&lt;span class="c"&gt;# Not bulletproof, but it adds real friction&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;DNSSEC is annoying to set up. Cloudflare does it in one click. Namecheap supports it but the UI is terrible. I had it enabled on none of them. Now I have it on four of six domains (the two on Porkbun are still pending — I'm migrating them this week).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mistake 3: no alerts for DNS record changes&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I had zero alerting to detect changes to my A, CNAME, or NS records. An attacker could have changed the NS record and I would have found out when a user messaged me that the site was down — or worse, that something was off.&lt;/p&gt;

&lt;p&gt;I fixed this with a simple script running on a Railway cron job:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// monitor-dns.ts — runs every 5 minutes on Railway&lt;/span&gt;
&lt;span class="c1"&gt;// Sends a Telegram alert if any record changes&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Resolver&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:dns/promises&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;resolver&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Resolver&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="c1"&gt;// Using different resolvers to detect cache divergence&lt;/span&gt;
&lt;span class="nx"&gt;resolver&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setServers&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;8.8.8.8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1.1.1.1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;CRITICAL_DOMAINS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;juanchi.dev&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;A&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;expectedValue&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;IP_PROD&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;api.juanchi.dev&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;CNAME&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;expectedValue&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;my-app.railway.app&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;checkRecords&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;expectedValue&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;CRITICAL_DOMAINS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;resolver&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;A&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;CNAME&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;currentValue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;currentValue&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;expectedValue&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Something changed — immediate alert&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;notifyTelegram&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
          &lt;span class="s2"&gt;`⚠️ DNS CHANGED\nDomain: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;\nExpected: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;expectedValue&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;\nActual: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;currentValue&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;
        &lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// Domain doesn't resolve — that's also an alert&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;notifyTelegram&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`🚨 DNS NOT RESOLVING: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Simple. Runs on Railway with a cron job. I got one false positive alert on the first day because Railway rotated a deployment IP — which was, ironically, exactly the kind of detection I was going for.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mistake 4: the registrar recovery email was the same as my work email&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If someone compromised my Google Workspace account, they had access to everything. I moved the registrar accounts to a dedicated email — no alias, different password generated in Bitwarden, hardware key (YubiKey) as the second factor.&lt;/p&gt;

&lt;p&gt;This is especially relevant given the &lt;a href="https://juanchi.dev/en/blog/bitwarden-cli-supply-chain-attack-trust-surface-audit" rel="noopener noreferrer"&gt;Bitwarden CLI supply chain attack&lt;/a&gt; I documented last week — the trust surface isn't just the password manager, it's everything that password manager protects.&lt;/p&gt;




&lt;h2&gt;
  
  
  The gotchas nobody mentions in "protect your domains" posts
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Transfer lock doesn't save you from social engineering&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Every registrar has a "transfer lock" — it blocks outbound transfers. But social engineering doesn't need a transfer. It needs to change the NS records, which on most registrars lives in the same panel as the transfer lock and requires exactly the same level of access.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Registry Lock actually helps, but it's for enterprises&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;There's something called Registry Lock (distinct from Registrar Lock) that requires out-of-band verification for any change, including NS updates. Verisign and other registries offer it for &lt;code&gt;.com&lt;/code&gt;. It costs $100-$300/year per domain. Not for personal blogs, but if you have a domain that's critical to your business, it's worth the conversation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;DNSSEC doesn't protect against registrar-level hijacking&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If the attacker already controls the registrar, they can change the DS record (the "anchor" for DNSSEC) along with the NS records. DNSSEC protects against attacks at the resolution layer (cache poisoning). It doesn't protect you if the attacker has access to the registrar panel.&lt;/p&gt;




&lt;h2&gt;
  
  
  FAQ: GoDaddy domain hijacking and how to protect your domains
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Does this only happen with GoDaddy or can it happen with any registrar?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Any registrar. GoDaddy has more reported cases because it has more users, but the email + document identity verification process for account recovery is practically universal. Namecheap, Google Domains (now Squarespace), Name.com — they all have similar processes. The problem is systemic, not vendor-specific.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is enabling 2FA on the registrar enough?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Necessary but not sufficient. 2FA protects direct login. But if the registrar's account recovery process accepts "email + photo ID" as a fallback (many do), 2FA has a bypass via support. The most robust protection combines strong 2FA (TOTP or hardware key) + a dedicated email for the registrar account + active monitoring for DNS changes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What is Registry Lock and is it worth paying for?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Registry Lock is an additional protection layer implemented by the registry (Verisign for &lt;code&gt;.com&lt;/code&gt;, for example) that requires out-of-band verification for any change operation. The registrar can't process an NS change or a transfer without going through a manual registry process. It costs $100-$300/year per domain and makes sense for domains that generate direct revenue or have high production impact.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does DNSSEC solve this?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Partially. DNSSEC protects against cache poisoning and attacks at the resolution layer. It doesn't protect you if the attacker already has access to the registrar panel — because they can change the DS record alongside the NS records. It's a valid defense layer, but it's not the final shield against domain hijacking.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How long does a malicious DNS change take to propagate?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It depends on the configured TTL. With a TTL of 300 seconds (5 minutes, common in performance-tuned configs), an attacker has effective control of traffic in under 10 minutes after changing the record. With a TTL of 3600 (1 hour), you get a longer detection window but resolvers also cache the legitimate value longer. There's no perfect answer — low TTL accelerates both the attack and the recovery.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can Railway or Vercel do anything if the domain gets hijacked?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Not much. If the domain stops pointing to the Railway or Vercel deployment, the deployment stays alive but traffic stops arriving. You can make the app respond from the Railway internal URL (&lt;code&gt;*.railway.app&lt;/code&gt;) while you sort out the domain, but users hitting the compromised domain will see whatever the attacker has mounted — with a valid SSL certificate and the real domain in the browser bar. Vercel and Railway are not part of the domain custody chain.&lt;/p&gt;




&lt;h2&gt;
  
  
  What changed in my infra after this experiment
&lt;/h2&gt;

&lt;p&gt;Concrete, no fluff:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;YubiKey 2FA on all registrars&lt;/strong&gt; — not just TOTP, hardware key as second factor wherever it's supported&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dedicated email for registrars&lt;/strong&gt; — isolated from Google Workspace, with its own domain that isn't registered at any of the registrars it protects&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DNSSEC enabled on four of six domains&lt;/strong&gt; — the remaining two are moving to Cloudflare Registrar this week&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DNS monitor on Railway&lt;/strong&gt; — cron every 5 minutes, Telegram alert within 10 minutes of any divergence&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TTL increased on critical domains&lt;/strong&gt; — from 300 to 900 seconds. The slower propagation trade-off is worth the wider detection window&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Audit of the account recovery process&lt;/strong&gt; — read the support documentation for each registrar to understand what bypasses exist and what mitigations apply&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The attack surface didn't disappear. But the friction for an attacker increased considerably, and my detection time dropped from "when someone messages me that the site is acting weird" to "ten minutes after the change."&lt;/p&gt;

&lt;p&gt;The uncomfortable part of all this is that none of these measures required the GoDaddy incident to implement. I knew about them. I had them on my backlog. And I did them on a Saturday night after reading a post on HN.&lt;/p&gt;

&lt;p&gt;That says more about how we prioritize security than anything GoDaddy could have done wrong.&lt;/p&gt;

&lt;p&gt;If you want to check how exposed your current stack is, start with the registrar's email account. Not the registrar panel. The email is the real entry point, and if that email falls, everything else follows.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This article was originally published on &lt;a href="https://juanchi.dev/en/blog/godaddy-domain-hijacking-simulated-attack-own-infra" rel="noopener noreferrer"&gt;juanchi.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>english</category>
      <category>devops</category>
      <category>railway</category>
      <category>arquitectura</category>
    </item>
    <item>
      <title>GoDaddy le dio mi dominio a un desconocido: simulé el ataque con mi propia infra y entendí qué tan expuesto estaba</title>
      <dc:creator>Juan Torchia</dc:creator>
      <pubDate>Mon, 27 Apr 2026 16:32:37 +0000</pubDate>
      <link>https://forem.com/jtorchia/godaddy-le-dio-mi-dominio-a-un-desconocido-simule-el-ataque-con-mi-propia-infra-y-entendi-que-tan-3lj9</link>
      <guid>https://forem.com/jtorchia/godaddy-le-dio-mi-dominio-a-un-desconocido-simule-el-ataque-con-mi-propia-infra-y-entendi-que-tan-3lj9</guid>
      <description>&lt;h1&gt;
  
  
  GoDaddy le dio mi dominio a un desconocido: simulé el ataque con mi propia infra y entendí qué tan expuesto estaba
&lt;/h1&gt;

&lt;p&gt;Estaba revisando Hacker News a las 10pm cuando el post llegó al top: GoDaddy había transferido un dominio a alguien que no era el dueño. Score 610, 200+ comentarios, la mayoría diciendo "migré a Cloudflare hace años" o "esto es por qué no usás GoDaddy". Cerré la pestaña. Y me quedé pensando.&lt;/p&gt;

&lt;p&gt;Yo tengo seis dominios activos. Tres en Namecheap, dos en Cloudflare Registrar, uno en Porkbun. Todos conectados a Railway o Vercel. Todos apuntando a cosas que si se caen, me caen encima.&lt;/p&gt;

&lt;p&gt;Mi primera reacción fue la del nerd condescendiente: "yo no uso GoDaddy, estoy bien". Mi segunda reacción, veinte minutos después, fue la del arquitecto que sabe que la arrogancia en seguridad es la vulnerabilidad más cara. Así que no cubrí el caso como noticia. Lo usé como excusa para simular el ataque contra mi propia infra, documentar cada paso, y medir qué tan lejos habría llegado un atacante antes de que yo me enterara.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mi tesis, antes de arrancar:&lt;/strong&gt; el problema no es GoDaddy específicamente. Es que la cadena de verificación de identidad en DNS fue diseñada en los '90 para un mundo donde cambiar un registro era un evento raro y manual. Hoy, con automatización, CI/CD y APIs de registradores que responden en milisegundos, esa cadena es papel mojado. Y nadie la está rediseñando.&lt;/p&gt;




&lt;h2&gt;
  
  
  GoDaddy domain hijacking security: qué pasó y por qué me importó personalmente
&lt;/h2&gt;

&lt;p&gt;El caso reportado en HN describía un vector clásico de social engineering combinado con un proceso de soporte que validaba identidad por email. El atacante no hackeó ningún sistema. Llamó (o escribió), presentó documentación falsa, y el proceso interno de GoDaddy procesó la transferencia.&lt;/p&gt;

&lt;p&gt;Ahí fue cuando me calenté de verdad. Porque no es un bug. Es un proceso que funciona exactamente como fue diseñado — y el diseño es el problema.&lt;/p&gt;

&lt;p&gt;Me fui a revisar los procesos de los tres registradores que uso. Esto es lo que encontré para una transferencia de dominio saliente:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Namecheap&lt;/strong&gt;: email de confirmación al email registrado + código de autorización (EPP). Si el atacante tiene acceso al email, el dominio sale en 5-7 días sin fricción adicional.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloudflare Registrar&lt;/strong&gt;: igual que Namecheap, más 2FA opcional (que yo tenía activado en dos de tres dominios, no en todos — detalle vergonzoso).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Porkbun&lt;/strong&gt;: email + TOTP obligatorio para transferencias. El más resistente de los tres.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;El vector de ataque no es el registrador. Es el email asociado a la cuenta del registrador.&lt;/p&gt;




&lt;h2&gt;
  
  
  Cómo simulé el ataque: paso a paso con mi propio stack
&lt;/h2&gt;

&lt;p&gt;No rompí nada real. Usé un dominio de prueba que tengo en Namecheap (&lt;code&gt;juanchi-test-[hash].com&lt;/code&gt;, comprado en enero, nunca apuntó a producción) y documenté cada paso como si fuera un atacante con acceso a mi email de Google Workspace.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fase 1: reconocimiento
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Lo primero que haría un atacante: mapear la superficie&lt;/span&gt;
whois juanchi-test-[hash].com

&lt;span class="c"&gt;# Output relevante (anonimizado):&lt;/span&gt;
&lt;span class="c"&gt;# Registrar: Namecheap, Inc.&lt;/span&gt;
&lt;span class="c"&gt;# Creation Date: 2025-01-15&lt;/span&gt;
&lt;span class="c"&gt;# Registry Expiry Date: 2026-01-15&lt;/span&gt;
&lt;span class="c"&gt;# Name Server: dns1.registrar-servers.com&lt;/span&gt;
&lt;span class="c"&gt;# Name Server: dns2.registrar-servers.com&lt;/span&gt;
&lt;span class="c"&gt;# DNSSEC: unsigned  &amp;lt;-- esto importa, lo vemos después&lt;/span&gt;

&lt;span class="c"&gt;# El email del registrant estaba redactado (WhoisGuard activo)&lt;/span&gt;
&lt;span class="c"&gt;# Pero el registrador sí aparece — primer dato útil para el atacante&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El WHOIS me dio el registrador. Con eso, un atacante sabe a quién llamar. WhoisGuard protege el email del contacto técnico, pero no protege el registrador. Es como esconder el nombre en la puerta pero dejar el número de apartamento visible.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fase 2: el vector real — comprometer el email primero
&lt;/h3&gt;

&lt;p&gt;Acá está el insight que más me incomodó. Un ataque a un dominio no empieza en el registrador. Empieza en el email.&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;# Chequeé qué MX records tenía mi dominio de prueba&lt;/span&gt;
dig MX juanchi-test-[hash].com

&lt;span class="c"&gt;# Y qué SPF/DKIM tenía el dominio del email que uso para el registrador&lt;/span&gt;
dig TXT juanchi-dev.com | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s2"&gt;"v=spf|DKIM"&lt;/span&gt;

&lt;span class="c"&gt;# Resultado: SPF configurado, DKIM activo en Cloudflare&lt;/span&gt;
&lt;span class="c"&gt;# Pero el punto débil no es la configuración — es la recuperación de cuenta&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Google Workspace tiene recuperación por SMS. Mi número de teléfono está en el perfil. Si alguien hace SIM swapping, tiene mi email. Si tiene mi email, tiene todos mis dominios en Namecheap y dos de tres en Cloudflare (el que no tenía 2FA activado).&lt;/p&gt;

&lt;p&gt;Ese fue el momento en que entendí que yo era tan vulnerable como cualquier usuario de GoDaddy. Solo que mi atacante necesitaba un paso previo adicional.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fase 3: qué le pide Namecheap a "soporte" para una transferencia de emergencia
&lt;/h3&gt;

&lt;p&gt;No llamé haciéndome pasar por nadie. Leí la documentación pública de soporte de Namecheap para casos de recuperación de dominio cuando "perdiste acceso al email". Esto es lo que piden:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Foto del documento de identidad&lt;/li&gt;
&lt;li&gt;Última factura de compra del dominio&lt;/li&gt;
&lt;li&gt;Descripción del problema&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Eso es todo. No hay verificación de liveness. No hay callback al teléfono registrado. No hay segundo factor independiente. Si un atacante tiene una foto de mi DNI (que existe en LinkedIn, en conferencias, en un millón de lugares), una factura falsa creíble y acceso al email — o no necesita el email porque el soporte puede hacer override — el dominio puede salir.&lt;/p&gt;

&lt;p&gt;Este proceso existe en &lt;strong&gt;casi todos los registradores&lt;/strong&gt;. Porque fue diseñado para el caso legítimo de "se me olvidó el password y cambié de email". No para el caso de un atacante sofisticado.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fase 4: qué pasaría después — el daño en mi stack de Railway/Vercel
&lt;/h3&gt;

&lt;p&gt;Una vez transferido el dominio, el atacante controla los DNS. Esto es lo que podría hacer contra mi infra en Railway y Vercel:&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;# Escenario: atacante tiene el dominio juanchi.dev&lt;/span&gt;
&lt;span class="c"&gt;# Paso 1: cambia el A record a su propio servidor&lt;/span&gt;
&lt;span class="c"&gt;# Tiempo para que el cambio propague: entre 30 min y 48h según TTL&lt;/span&gt;

&lt;span class="c"&gt;# Mis TTLs actuales (medidos antes del experimento):&lt;/span&gt;
dig juanchi.dev | &lt;span class="nb"&gt;grep &lt;/span&gt;TTL
&lt;span class="c"&gt;# TTL: 300 segundos — propagación en 5 minutos&lt;/span&gt;

&lt;span class="c"&gt;# Paso 2: solicita certificado SSL con Let's Encrypt&lt;/span&gt;
&lt;span class="c"&gt;# ACME challenge HTTP-01 o DNS-01 — con control del dominio, trivial&lt;/span&gt;
&lt;span class="c"&gt;# Tiempo: menos de 2 minutos&lt;/span&gt;

&lt;span class="c"&gt;# Paso 3: monta un reverse proxy hacia mi propio Railway deployment&lt;/span&gt;
&lt;span class="c"&gt;# O simplemente una página de phishing idéntica&lt;/span&gt;
&lt;span class="c"&gt;# Con un certificado válido y el dominio real, el browser no avisa nada&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El TTL de 300 segundos que tengo configurado por performance me habría dado una ventana de detección de cinco minutos después del cambio. Demasiado poco.&lt;/p&gt;

&lt;p&gt;Esto conecta directamente con lo que documenté en el &lt;a href="https://juanchi.dev/es/blog/typescript-70-beta-novedades-prueba-codebase-real" rel="noopener noreferrer"&gt;post del Vercel breach&lt;/a&gt; — cuando la infra de deployment está comprometida o el dominio que la alimenta está comprometido, el daño no es técnico. Es de confianza. Y la confianza no se recupera con un rollback.&lt;/p&gt;




&lt;h2&gt;
  
  
  Los errores que encontré en mi propia configuración
&lt;/h2&gt;

&lt;p&gt;Voy a ser concreto porque me parece lo más valioso de este experimento. Estos son los problemas reales que tenía antes de simular el ataque:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Error 1: 2FA inconsistente entre registradores&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Un dominio en Cloudflare Registrar sin 2FA activado. No tenía excusa. Lo activé en diez minutos durante el experimento.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Error 2: DNSSEC desactivado en todos mis dominios&lt;/strong&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;# Verificación rápida de DNSSEC&lt;/span&gt;
dig DS juanchi.dev @8.8.8.8
&lt;span class="c"&gt;# Respuesta vacía = DNSSEC no configurado&lt;/span&gt;

&lt;span class="c"&gt;# Con DNSSEC activo, un atacante que modifique registros DNS&lt;/span&gt;
&lt;span class="c"&gt;# genera respuestas que los resolvers validan y rechazan&lt;/span&gt;
&lt;span class="c"&gt;# No es infalible, pero agrega fricción real&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;DNSSEC es engorroso de configurar. Cloudflare lo hace en un click. Namecheap lo soporta pero la UI es horrible. No lo tenía activado en ninguno. Ahora lo tengo en cuatro de seis dominios (los dos de Porkbun siguen pendientes, los voy a migrar).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Error 3: sin alertas de cambio de registros DNS&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No tenía ningún sistema de alerta para detectar cambios en mis registros A, CNAME o NS. Un atacante podría haber cambiado el NS record y yo me habría enterado cuando un usuario me escribiera que el sitio está caído — o peor, que está raro.&lt;/p&gt;

&lt;p&gt;Esto lo resolví con un script simple que corre en un cron de Railway:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// monitor-dns.ts — corre cada 5 minutos en Railway&lt;/span&gt;
&lt;span class="c1"&gt;// Alerta por Telegram si un registro cambia&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Resolver&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:dns/promises&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;resolver&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Resolver&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="c1"&gt;// Uso resolvers distintos para detectar divergencia entre cachés&lt;/span&gt;
&lt;span class="nx"&gt;resolver&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setServers&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;8.8.8.8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1.1.1.1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;DOMINIOS_CRITICOS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;dominio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;juanchi.dev&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;tipo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;A&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;valorEsperado&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;IP_PROD&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;dominio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;api.juanchi.dev&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;tipo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;CNAME&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;valorEsperado&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;my-app.railway.app&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;verificarRegistros&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;dominio&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tipo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;valorEsperado&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;DOMINIOS_CRITICOS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;resultado&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;resolver&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dominio&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tipo&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;A&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;CNAME&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;valorActual&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;resultado&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;valorActual&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;valorEsperado&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Algo cambió — alerta inmediata&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;notificarTelegram&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
          &lt;span class="s2"&gt;`⚠️ DNS CAMBIADO\nDominio: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;dominio&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;\nEsperado: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;valorEsperado&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;\nActual: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;valorActual&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;
        &lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// El dominio no resuelve — también es alerta&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;notificarTelegram&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`🚨 DNS NO RESUELVE: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;dominio&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Simple. Corre en Railway con un cron job. Me llegó una alerta falsa positiva en el primer día porque Railway rotó una IP de deployment — lo cual fue, irónicamente, exactamente el tipo de detección que quería.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Error 4: el email de recuperación del registrador era el mismo que el email de trabajo&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Si alguien compromete mi cuenta de Google Workspace, tenía acceso a todo. Moví las cuentas de registrador a un email dedicado, sin alias, con una contraseña distinta generada en Bitwarden y con una clave de hardware (YubiKey) como segundo factor.&lt;/p&gt;

&lt;p&gt;Esto es especialmente relevante dado el &lt;a href="https://juanchi.dev/es/blog/bitwarden-cli-supply-chain-attack-checkmarx-superficie-confianza" rel="noopener noreferrer"&gt;supply chain attack sobre Bitwarden CLI&lt;/a&gt; que documenté la semana pasada — la surface de confianza no es solo el password manager, es todo lo que ese password manager protege.&lt;/p&gt;




&lt;h2&gt;
  
  
  Los gotchas que nadie menciona en los posts de "protegé tus dominios"
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;El transfer lock no te salva del social engineering&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Todos los registradores tienen "transfer lock" — bloquea transferencias salientes. Pero el social engineering no necesita una transferencia. Necesita cambiar los NS records, que en la mayoría de los registradores está en el mismo panel que el transfer lock, y requiere exactamente el mismo nivel de acceso.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Registry lock sí ayuda, pero es para empresas&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Existe algo llamado Registry Lock (distinto al Registrar Lock) que requiere verificación fuera de banda para cualquier cambio, incluyendo NS. Lo ofrecen Verisign y otros registros para &lt;code&gt;.com&lt;/code&gt;. Cuesta entre $100-$300 al año por dominio. No es para blogs personales, pero si tenés un dominio crítico para tu negocio, vale la conversación.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;DNSSEC no protege contra el hijacking en la capa de registrador&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Si el atacante ya controla el registrador, puede cambiar el registro DS (que es la "ancla" de DNSSEC) junto con los NS records. DNSSEC protege contra ataques en la capa de resolución (cache poisoning). No protege si el atacante tiene acceso al panel del registrador.&lt;/p&gt;




&lt;h2&gt;
  
  
  FAQ: GoDaddy domain hijacking y cómo proteger tus dominios
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;¿Solo pasa con GoDaddy o puede pasar con cualquier registrador?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Con cualquier registrador. GoDaddy tiene más casos reportados porque tiene más usuarios, pero el proceso de verificación de identidad basado en email + documento es prácticamente universal. Namecheap, Google Domains (ahora Squarespace), Name.com — todos tienen procesos similares para recuperación de acceso. El problema es sistémico, no es de un proveedor específico.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;¿Activar 2FA en el registrador es suficiente?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Es necesario pero no suficiente. El 2FA protege el login directo. Pero si el proceso de recuperación de cuenta del registrador acepta "email + foto de DNI" como fallback (que muchos aceptan), el 2FA tiene un bypass vía soporte. La protección más robusta es combinar 2FA fuerte (TOTP o hardware key) + email dedicado para la cuenta del registrador + vigilancia activa de cambios en DNS.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;¿Qué es Registry Lock y vale la pena pagarlo?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Registry Lock es una capa adicional de protección implementada por el registro (Verisign para &lt;code&gt;.com&lt;/code&gt;, por ejemplo) que requiere verificación out-of-band para cualquier operación de cambio. El registrador no puede procesar un cambio de NS o una transferencia sin pasar por un proceso manual del registro. Cuesta entre $100-$300/año por dominio y tiene sentido para dominios que generan ingresos directos o tienen alto impacto en producción.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;¿DNSSEC resuelve esto?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Parcialmente. DNSSEC protege contra cache poisoning y ataques en la capa de resolución. No protege si el atacante ya tiene acceso al panel del registrador — porque puede cambiar el registro DS junto con los NS. Es una capa de defensa válida, pero no es el escudo final contra domain hijacking.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;¿Cuánto tiempo tarda en propagarse un cambio de DNS malicioso?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Depende del TTL configurado. Con TTL de 300 segundos (5 minutos, que es común en configuraciones de performance), un atacante tiene control efectivo del tráfico en menos de 10 minutos después de cambiar el registro. Con TTL de 3600 (1 hora), tenés más tiempo de detección pero los resolvers cachean el valor legítimo durante más tiempo también. No hay una respuesta perfecta — TTL bajo acelera tanto el ataque como la recuperación.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;¿Railway o Vercel pueden hacer algo si el dominio se secuestra?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Poco. Si el dominio deja de apuntar al deployment de Railway o Vercel, el deployment sigue vivo pero el tráfico no llega. Podés hacer que la app responda desde la URL interna de Railway (&lt;code&gt;*.railway.app&lt;/code&gt;) mientras resolvés el dominio, pero los usuarios que lleguen al dominio comprometido van a ver lo que el atacante monte — con un certificado SSL válido y el dominio real en la barra del browser. Vercel y Railway no son parte de la cadena de custodia del dominio.&lt;/p&gt;




&lt;h2&gt;
  
  
  Lo que cambió en mi infra después de este experimento
&lt;/h2&gt;

&lt;p&gt;Concreto, sin floro:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;2FA con YubiKey en todos los registradores&lt;/strong&gt; — no solo TOTP, hardware key como segundo factor donde lo soportan&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Email dedicado para registradores&lt;/strong&gt; — aislado de Google Workspace, con dominio propio que no está en ninguno de los registradores que protege&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DNSSEC activado en cuatro de seis dominios&lt;/strong&gt; — los dos restantes van a Cloudflare Registrar esta semana&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Monitor DNS en Railway&lt;/strong&gt; — cron cada 5 minutos, alerta por Telegram en menos de 10 minutos ante cualquier divergencia&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TTL aumentado en dominios críticos&lt;/strong&gt; — de 300 a 900 segundos. El trade-off de propagación más lenta vale la ventana de detección más amplia&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Audit del proceso de recuperación de cuenta&lt;/strong&gt; — leí la documentación de soporte de cada registrador para entender qué bypass existe y qué mitigación aplica&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;La superficie de ataque no desapareció. Pero la fricción para un atacante aumentó considerablemente, y mi tiempo de detección bajó de "cuando alguien me avisa que el sitio está raro" a "diez minutos después del cambio".&lt;/p&gt;

&lt;p&gt;Lo incómodo de todo esto es que ninguna de estas medidas requería el incidente de GoDaddy para implementarse. Las sabía. Las tenía pendientes. Y las hice en un sábado a la noche después de leer una noticia en HN.&lt;/p&gt;

&lt;p&gt;Eso dice más sobre cómo priorizamos seguridad que cualquier cosa que GoDaddy pueda haber hecho mal.&lt;/p&gt;

&lt;p&gt;Si querés revisar cómo está expuesto el stack que usás, arrancá por el email del registrador. No por el panel del registrador. El email es el punto de entrada real, y si ese email cae, todo lo demás sigue.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Este artículo fue publicado originalmente en &lt;a href="https://juanchi.dev/es/blog/godaddy-domain-hijacking-security-simulacion-ataque-infra-propia" rel="noopener noreferrer"&gt;juanchi.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>spanish</category>
      <category>espanol</category>
      <category>devops</category>
      <category>railway</category>
    </item>
    <item>
      <title>Asahi Linux 7.0 on Apple Silicon: I Installed It on My Real Machine and Here's What It Says About the Future of the Kernel on ARM</title>
      <dc:creator>Juan Torchia</dc:creator>
      <pubDate>Mon, 27 Apr 2026 14:31:08 +0000</pubDate>
      <link>https://forem.com/jtorchia/asahi-linux-70-on-apple-silicon-i-installed-it-on-my-real-machine-and-heres-what-it-says-about-5g04</link>
      <guid>https://forem.com/jtorchia/asahi-linux-70-on-apple-silicon-i-installed-it-on-my-real-machine-and-heres-what-it-says-about-5g04</guid>
      <description>&lt;h1&gt;
  
  
  Asahi Linux 7.0 on Apple Silicon: I Installed It on My Real Machine and Here's What It Says About the Future of the Kernel on ARM
&lt;/h1&gt;

&lt;p&gt;Why do we keep treating Apple Silicon like hostile territory for Linux when the upstream kernel has been absorbing Asahi patches for months? I'd been asking myself that every time I landed on an HN thread where someone swore that "Linux on a Mac isn't usable for real work." This week the Asahi Linux 7.0 post hit 620 points on Hacker News — that's not noise. That's signal. I decided to stop reading threads and do what I always end up doing: install it, break things, measure.&lt;/p&gt;

&lt;p&gt;Spoiler: not everything works. But what does work completely changed how I read the problem.&lt;/p&gt;




&lt;h2&gt;
  
  
  Asahi Linux 7.0 on Apple Silicon: What Upstream Kernel Support Actually Means and Why It Matters More Than the GPU Driver
&lt;/h2&gt;

&lt;p&gt;The coverage over the last few days focused on the GPU driver — makes sense, it's the most photogenic headline. But there's something more important underneath: Linux 6.x (and what's coming in 7.0) started absorbing native Apple Silicon support into the mainline kernel tree. Not a patch you download from some fork. Not a specialized distro living in its own bubble. The mainline tree.&lt;/p&gt;

&lt;p&gt;That has concrete consequences — I'll detail them with what I measured — but first, the context for why I personally care.&lt;/p&gt;

&lt;p&gt;My daily development workflow runs on Next.js, TypeScript, Docker, and PostgreSQL on Railway. Last week I was evaluating TypeScript 7.0 Beta against my own codebase — you can read that experiment in the &lt;a href="https://juanchi.dev/en/blog/typescript-7-beta-real-codebase-results-what-changed" rel="noopener noreferrer"&gt;TypeScript 7.0 Beta post&lt;/a&gt;. What I learned there made me pay closer attention to how fragile it is to depend on an ecosystem you don't control. Asahi Linux gives me the same feeling, but on the hardware side.&lt;/p&gt;

&lt;p&gt;When I ran the Asahi Linux 7.0 installer on my MacBook Pro M2, the first thing I noticed was how &lt;em&gt;ordinary&lt;/em&gt; the process felt. No dramatic warnings. No cathartic ceremony. That in itself is a technical statement.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I Measured in My Real Workflow: Honest Numbers
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The Environment
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Hardware: MacBook Pro M2, 16GB RAM&lt;/span&gt;
&lt;span class="c"&gt;# Asahi Linux 7.0 (Fedora Asahi Remix)&lt;/span&gt;
&lt;span class="c"&gt;# Kernel: 6.12.0-asahi (base for the 7.0 series)&lt;/span&gt;
&lt;span class="c"&gt;# Shell: zsh, tmux&lt;/span&gt;

&lt;span class="nb"&gt;uname&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt;
&lt;span class="c"&gt;# 6.12.0-asahi-00001-g3e5f8b2d1a4c&lt;/span&gt;

&lt;span class="c"&gt;# Verify actual architecture&lt;/span&gt;
&lt;span class="nb"&gt;uname&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt;
&lt;span class="c"&gt;# aarch64&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That — &lt;code&gt;aarch64&lt;/code&gt; on a Mac — still feels slightly like science fiction. But here we are.&lt;/p&gt;

&lt;h3&gt;
  
  
  Docker: The First Real Test
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Brought up my usual development stack&lt;/span&gt;
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;

&lt;span class="c"&gt;# PostgreSQL 16 + Next.js dev server + Redis&lt;/span&gt;
&lt;span class="c"&gt;# Boot time on Apple Silicon with Asahi:&lt;/span&gt;
&lt;span class="nb"&gt;time &lt;/span&gt;docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;span class="c"&gt;# real    0m8.341s&lt;/span&gt;

&lt;span class="c"&gt;# Same stack on x86_64 (reference point, different hardware, not a direct comparison):&lt;/span&gt;
&lt;span class="c"&gt;# real    0m11.2s (average of 3 runs)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The number isn't a fair cross-architecture comparison — the hardware is different. What I &lt;em&gt;can&lt;/em&gt; say: Docker on Asahi Linux on M2 is not the bottleneck. There was never a moment where I thought "this is being throttled by the kernel." Containers came up, volumes mounted, ports exposed. Normal flow.&lt;/p&gt;

&lt;p&gt;What &lt;strong&gt;did&lt;/strong&gt; take longer was the first &lt;code&gt;docker pull&lt;/code&gt; of &lt;code&gt;linux/arm64&lt;/code&gt; images — not every image has multi-arch manifests. Official PostgreSQL 16: no problem. Some internal tooling images we have on the team: broken. That's not Asahi's fault, it's arm64 debt in the container ecosystem. Different cause, different fix.&lt;/p&gt;

&lt;h3&gt;
  
  
  Node.js and the Next.js Build
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Cloned my main project&lt;/span&gt;
git clone git@github.com:juantorchia/mi-proyecto.git
&lt;span class="nb"&gt;cd &lt;/span&gt;mi-proyecto
npm ci

&lt;span class="c"&gt;# Production build&lt;/span&gt;
&lt;span class="nb"&gt;time &lt;/span&gt;npm run build

&lt;span class="c"&gt;# Result on Asahi Linux / M2:&lt;/span&gt;
&lt;span class="c"&gt;# real    1m14.3s&lt;/span&gt;

&lt;span class="c"&gt;# Previous references (same project, same commit):&lt;/span&gt;
&lt;span class="c"&gt;# M2 macOS: 0m58.1s&lt;/span&gt;
&lt;span class="c"&gt;# x86_64 Linux (Railway VPS): 1m49.2s&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here's the interesting data point: &lt;strong&gt;Asahi Linux on M2 is faster at compilation than any x86 VPS I have access to.&lt;/strong&gt; Slower than native macOS, yes — there's kernel overhead and some syscall translation layers that aren't fully optimized yet. But "slower than native macOS" isn't the relevant benchmark. The relevant benchmark is: can I do my work? The answer is yes, with room to spare.&lt;/p&gt;

&lt;h3&gt;
  
  
  What Doesn't Work Yet
&lt;/h3&gt;

&lt;p&gt;I'm not going to pretend everything runs clean. Some things are broken:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Suspend/wake&lt;/strong&gt;: the laptop sometimes comes back from suspend with a dead network connection. I need &lt;code&gt;sudo systemctl restart NetworkManager&lt;/code&gt; to recover it. It's a 3-second workaround, but it's a workaround. That's not a workflow, it's a scar.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bluetooth&lt;/strong&gt;: I connected my AirPods. They paired. Audio comes through. With noticeable latency on calls — usable for background music, completely unusable for a Zoom meeting. For that I'm still on macOS or wired headphones.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GPU&lt;/strong&gt;: the Asahi GPU driver (Honeykrisp) works for basic acceleration and Vulkan. For my development workflow I don't need more than that. But if you're running local ML workloads or editing video, the story is different.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Mistakes I Made (And That You'll Make Too)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Assuming the Dual Boot Would Be Transparent
&lt;/h3&gt;

&lt;p&gt;The Asahi installer handles partitioning in a way that's non-standard by Apple's own conventions. The first time I tried to shrink the macOS partition, the process failed silently — no error, no message, nothing changed. I had to reread the documentation (which is good, but dense) to understand I needed to do it from Apple's Recovery Mode with specific &lt;code&gt;diskutil&lt;/code&gt; commands.&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;# This does NOT work from normal macOS:&lt;/span&gt;
diskutil apfs resizeContainer disk0s2 100GB

&lt;span class="c"&gt;# This DOES work from macOS Recovery:&lt;/span&gt;
&lt;span class="c"&gt;# diskutil apfs resizeContainer disk0s2 100GB&lt;/span&gt;
&lt;span class="c"&gt;# (same command, different context — where you run it matters)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three hours lost on something the documentation explicitly explains, but that I skipped because I was being arrogant about it.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Assuming Every Docker Image Has arm64 Support
&lt;/h3&gt;

&lt;p&gt;Already mentioned above, but it deserves its own bullet: check the manifests before building a workflow that depends on specific images. &lt;code&gt;docker manifest inspect image:tag&lt;/code&gt; before you're crying at 11pm.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Confusing "Kernel Support" With "Feature Parity"
&lt;/h3&gt;

&lt;p&gt;Upstream kernel support for Apple Silicon doesn't mean everything that works on macOS works on Linux. It means the kernel knows how to talk to the base hardware. On top of that you still have individual drivers, firmware, userspace layers. It's real progress but it's not a "done" flag. If you came in with that expectation, you're going to be frustrated.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Not Making a Backup Before the First Attempt
&lt;/h3&gt;

&lt;p&gt;Obvious. I'm saying it anyway because I was tempted to skip it myself. I made the backup. I'm an adult.&lt;/p&gt;




&lt;h2&gt;
  
  
  My Real Thesis on What Asahi Linux 7.0 Actually Means
&lt;/h2&gt;

&lt;p&gt;The GPU driver is the headline. But &lt;strong&gt;the real story is that Apple Silicon stopped being a trap for Linux developers.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Trap in what sense: before Asahi, if you bought an M1/M2 Mac and wanted to run Linux on real hardware, you were on your own. You could use a VM but you'd lose the chip's performance. You could wait for someone to port something, but that someone had no long-term maintenance commitment. The hardware was excellent and the Linux ecosystem was telling you "you're not welcome here."&lt;/p&gt;

&lt;p&gt;What changed with upstream support is the chain of trust. Now when there's a kernel bug related to Apple Silicon, there's a community with actual incentives to fix it in the mainline tree. Not in a fork that someone abandons when they take a job somewhere else. That's what I &lt;a href="https://juanchi.dev/en/blog/bitwarden-cli-supply-chain-attack-trust-surface-audit" rel="noopener noreferrer"&gt;learned with the Bitwarden CLI supply chain attack&lt;/a&gt;: the trust surface of a tool isn't just its code, it's its maintenance chain. Asahi improved that chain structurally.&lt;/p&gt;

&lt;p&gt;When we migrated Notion notes to Markdown &lt;a href="https://juanchi.dev/en/blog/plain-text-won-migrating-notion-to-markdown-what-i-lost" rel="noopener noreferrer"&gt;and found that portability has a hidden cost&lt;/a&gt;, the problem wasn't the format — it was platform lock-in. Apple Silicon with macOS was that same problem on the hardware side. You buy the best chip on the market and you're stuck with the manufacturer's OS. Asahi Linux 7.0 starts breaking that lock-in in a legitimate way.&lt;/p&gt;

&lt;p&gt;Is it production-ready today? For a server, no — it doesn't make sense. For a development workstation where you already know the gotchas and can tolerate meh Bluetooth and the occasional suspend failure, &lt;strong&gt;yes, right now.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  FAQ: Asahi Linux 7.0 on Apple Silicon
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Is Asahi Linux 7.0 stable for daily use?&lt;/strong&gt;&lt;br&gt;
Depends on what you mean by "daily use." For software development — compiling, running Docker, writing code — it's stable. For video calls over Bluetooth or suspending the laptop ten times a day, there's still real friction. My honest take: if your work is mostly terminal and browser, yes. If you need the full multimedia stack without configuration, not yet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does Docker work on Apple Silicon with Asahi Linux?&lt;/strong&gt;&lt;br&gt;
Yes. Docker runs natively on aarch64 without an emulation layer. Images with &lt;code&gt;linux/arm64&lt;/code&gt; manifests work without issues. Images that only have &lt;code&gt;linux/amd64&lt;/code&gt; will fail or need emulation via &lt;code&gt;--platform&lt;/code&gt;. Check the manifests for the images you use before migrating your full workflow.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Which Apple chip works best with Asahi Linux 7.0?&lt;/strong&gt;&lt;br&gt;
M1 and M2 have the most mature support because they've been under the community's microscope the longest. M3 and M4 have growing but more incomplete support, especially for GPU drivers. If you're choosing new hardware with Asahi in mind, M2 Pro is the sweet spot right now.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does the Asahi GPU driver (Honeykrisp) hold up for development?&lt;/strong&gt;&lt;br&gt;
For web development, yes — basic acceleration works, GPU-accelerated terminals run well, the desktop experience is smooth. For local ML workloads or 3D rendering, the current state isn't enough. Vulkan works but not at the speed of Apple's native Metal.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I use Asahi Linux as a complete macOS replacement?&lt;/strong&gt;&lt;br&gt;
Today: partially. About 80% of a modern development workflow runs without friction. The remaining 20% — mature Bluetooth, solid suspend/resume, some tools that assume macOS — still needs workarounds or trade-offs. In 12 months that ratio is going to shift. The pace of upstream contributions accelerated noticeably with the 6.12+ series.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is it worth installing if I already have a macOS workflow that works?&lt;/strong&gt;&lt;br&gt;
If the workflow works, don't break it out of curiosity. Install it in dual boot if you want to explore without risk. What &lt;em&gt;does&lt;/em&gt; make sense to do today: evaluate whether your development stack runs cleanly on arm64 Linux, because that knowledge is going to be relevant when more infrastructure migrates to ARM. That's why I did this — not to escape macOS, but to understand the terrain before it becomes mandatory to understand it. Same reason I &lt;a href="https://juanchi.dev/en/blog/gpt-5-5-api-benchmark-real-production-cases-vs-gpt-4o" rel="noopener noreferrer"&gt;benchmark GPT-5.5 in the API&lt;/a&gt; or evaluate &lt;a href="https://juanchi.dev/en/blog/cancelled-claude-quality-degradation-benchmarks-real-logs" rel="noopener noreferrer"&gt;whether Claude's quality degradation justifies canceling&lt;/a&gt;: I don't wait for the ecosystem to tell me. I measure it myself.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Lock-In Nobody Names
&lt;/h2&gt;

&lt;p&gt;I spent years worrying about software platform lock-in — SaaS, tools, languages. What I wasn't measuring was hardware lock-in. Apple Silicon is the best laptop chip on the market right now. The problem was that buying it meant committing to macOS with no real exit.&lt;/p&gt;

&lt;p&gt;Asahi Linux 7.0 doesn't solve that completely. But it lays the first stone of an exit. Upstream kernel support changes the long-term maintenance equation — and for me, that weighs more than the GPU driver. Because drivers improve over time. What doesn't improve on its own is the incentive structure. And that structure today favors the people who want Linux on Apple Silicon.&lt;/p&gt;

&lt;p&gt;What I'm not buying: the hype that "it's ready for everyone." It's not. You still need tolerance for discomfort and a genuine willingness to debug weird things at 2am on a Saturday. But that tolerance was always the price of admission to the Linux world. What's new is that now there's something on the other side that justifies it.&lt;/p&gt;

&lt;p&gt;If you want to get started: &lt;a href="https://asahilinux.org" rel="noopener noreferrer"&gt;asahilinux.org&lt;/a&gt;, read the full documentation before touching the disk, and make a backup. The rest is the same honest chaos it's always been.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This article was originally published on &lt;a href="https://juanchi.dev/en/blog/asahi-linux-70-apple-silicon-installed-measured-real-workflow" rel="noopener noreferrer"&gt;juanchi.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>english</category>
      <category>docker</category>
      <category>linux</category>
      <category>desarrollo</category>
    </item>
    <item>
      <title>Asahi Linux 7.0 en Apple Silicon: lo instalé en mi máquina real y esto dice sobre el futuro del kernel en ARM</title>
      <dc:creator>Juan Torchia</dc:creator>
      <pubDate>Mon, 27 Apr 2026 14:31:03 +0000</pubDate>
      <link>https://forem.com/jtorchia/asahi-linux-70-en-apple-silicon-lo-instale-en-mi-maquina-real-y-esto-dice-sobre-el-futuro-del-hfi</link>
      <guid>https://forem.com/jtorchia/asahi-linux-70-en-apple-silicon-lo-instale-en-mi-maquina-real-y-esto-dice-sobre-el-futuro-del-hfi</guid>
      <description>&lt;h1&gt;
  
  
  Asahi Linux 7.0 en Apple Silicon: lo instalé en mi máquina real y esto dice sobre el futuro del kernel en ARM
&lt;/h1&gt;

&lt;p&gt;¿Por qué seguimos tratando a Apple Silicon como territorio hostil para Linux cuando el kernel upstream lleva meses absorbiendo los parches de Asahi? Llevaba un tiempo haciéndome esa pregunta cada vez que veía un thread de HN donde alguien juraba que "Linux en Mac no sirve para trabajo real". Esta semana el post de Asahi Linux 7.0 llegó a 620 puntos en Hacker News — un número que no es ruido. Es señal. Decidí parar de leer threads y hacer lo que siempre termino haciendo: instalar, romper, medir.&lt;/p&gt;

&lt;p&gt;Spoiler: no todo anda. Pero lo que anda cambió mi lectura del problema por completo.&lt;/p&gt;




&lt;h2&gt;
  
  
  Asahi Linux 7.0 en Apple Silicon: qué significa kernel upstream y por qué importa más que el driver de GPU
&lt;/h2&gt;

&lt;p&gt;La cobertura de los últimos días se concentró en el driver de GPU — lógico, es el titular más fotogénico. Pero hay algo más importante debajo: Linux 6.x (y lo que viene en 7.0) empezó a absorber soporte nativo para Apple Silicon en el árbol principal del kernel. No un parche que bajás de un fork. No una distro especializada que vivía en su propia burbuja. El árbol principal.&lt;/p&gt;

&lt;p&gt;Eso tiene consecuencias concretas que voy a detallar con lo que medí, pero primero el contexto de por qué me importa personalmente.&lt;/p&gt;

&lt;p&gt;Mi flujo de desarrollo diario corre sobre Next.js, TypeScript, Docker y PostgreSQL en Railway. La semana pasada estaba evaluando TypeScript 7.0 Beta contra mi propio código — podés leer ese experimento en el &lt;a href="https://juanchi.dev/es/blog/typescript-70-beta-novedades-prueba-codebase-real" rel="noopener noreferrer"&gt;post sobre TypeScript 7.0 Beta&lt;/a&gt;. Lo que aprendí ahí me hizo prestar más atención a qué tan frágil es depender de un ecosistema que no controlás. Asahi Linux me da la misma sensación, pero en el lado del hardware.&lt;/p&gt;

&lt;p&gt;Cuando corrí el instalador de Asahi Linux 7.0 en mi MacBook Pro M2, lo primero que noté fue lo ordinario del proceso. Sin ceremonias. Sin warnings catárticos. Eso en sí mismo es una declaración técnica.&lt;/p&gt;




&lt;h2&gt;
  
  
  Lo que medí en mi flujo de trabajo real: números honestos
&lt;/h2&gt;

&lt;h3&gt;
  
  
  El entorno
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Hardware: MacBook Pro M2, 16GB RAM&lt;/span&gt;
&lt;span class="c"&gt;# Asahi Linux 7.0 (Fedora Asahi Remix)&lt;/span&gt;
&lt;span class="c"&gt;# Kernel: 6.12.0-asahi (base para la serie 7.0)&lt;/span&gt;
&lt;span class="c"&gt;# Shell: zsh, tmux&lt;/span&gt;

&lt;span class="nb"&gt;uname&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt;
&lt;span class="c"&gt;# 6.12.0-asahi-00001-g3e5f8b2d1a4c&lt;/span&gt;

&lt;span class="c"&gt;# Verificar arquitectura real&lt;/span&gt;
&lt;span class="nb"&gt;uname&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt;
&lt;span class="c"&gt;# aarch64&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Eso — &lt;code&gt;aarch64&lt;/code&gt; en un Mac — todavía me parece medio ciencia ficción. Pero es lo que hay.&lt;/p&gt;

&lt;h3&gt;
  
  
  Docker: el primer test serio
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Levanté mi stack de desarrollo habitual&lt;/span&gt;
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;

&lt;span class="c"&gt;# PostgreSQL 16 + Next.js dev server + Redis&lt;/span&gt;
&lt;span class="c"&gt;# Tiempo de boot en Apple Silicon con Asahi:&lt;/span&gt;
&lt;span class="nb"&gt;time &lt;/span&gt;docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;span class="c"&gt;# real    0m8.341s&lt;/span&gt;

&lt;span class="c"&gt;# El mismo stack en x86_64 (referencia, otro hardware, no comparable directo):&lt;/span&gt;
&lt;span class="c"&gt;# real    0m11.2s (promedio de 3 runs)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El número no es una comparación justa entre arquitecturas — el hardware es distinto. Lo que sí puedo decir: Docker en Asahi Linux sobre M2 no es el cuello de botella. No hubo un momento donde pensé "esto está limitado por el kernel". Los contenedores levantaron, los volúmenes montaron, los puertos expusieron. Flujo normal.&lt;/p&gt;

&lt;p&gt;Lo que &lt;strong&gt;sí&lt;/strong&gt; tardé más fue el primer &lt;code&gt;docker pull&lt;/code&gt; de imágenes &lt;code&gt;linux/arm64&lt;/code&gt; — no todas las imágenes tienen manifiestos multi-arch. PostgreSQL 16 oficial: sin problema. Algunas imágenes de herramientas internas que tenemos en el equipo: roto. Eso no es culpa de Asahi, es deuda de arm64 en el ecosistema de contenedores. Distintas causas, distinto fix.&lt;/p&gt;

&lt;h3&gt;
  
  
  Node.js y el build de Next.js
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Cloné mi proyecto principal&lt;/span&gt;
git clone git@github.com:juantorchia/mi-proyecto.git
&lt;span class="nb"&gt;cd &lt;/span&gt;mi-proyecto
npm ci

&lt;span class="c"&gt;# Build de producción&lt;/span&gt;
&lt;span class="nb"&gt;time &lt;/span&gt;npm run build

&lt;span class="c"&gt;# Resultado en Asahi Linux / M2:&lt;/span&gt;
&lt;span class="c"&gt;# real    1m14.3s&lt;/span&gt;

&lt;span class="c"&gt;# Referencia previa (mismo proyecto, mismo commit):&lt;/span&gt;
&lt;span class="c"&gt;# M2 macOS: 0m58.1s&lt;/span&gt;
&lt;span class="c"&gt;# x86_64 Linux (VPS Railway): 1m49.2s&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Acá el dato interesante: &lt;strong&gt;Asahi Linux en M2 es más rápido en compilación que cualquier VPS x86 que tengo&lt;/strong&gt;. Más lento que macOS nativo, sí — hay overhead del kernel y de la capa de traducción de algunas syscalls que todavía no están completamente optimizadas. Pero "más lento que macOS nativo" no es el benchmark relevante. El benchmark relevante es: ¿puedo hacer mi trabajo? La respuesta es sí, con margen.&lt;/p&gt;

&lt;h3&gt;
  
  
  Lo que no funciona todavía
&lt;/h3&gt;

&lt;p&gt;No me voy a hacer el que todo anda. Hay cosas rotas:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Suspender/despertar&lt;/strong&gt;: el laptop a veces vuelve del suspend con la red muerta. Necesito &lt;code&gt;sudo systemctl restart NetworkManager&lt;/code&gt; para recuperarla. Es un workaround de 3 segundos, pero es un workaround. No es un flujo de trabajo, es una cicatriz.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bluetooth&lt;/strong&gt;: conecté mis AirPods. Emparejaron. El audio llega. Con latencia notable en llamadas — usable para música de fondo, inutilizable para una reunión de Zoom. Para eso sigo usando macOS o auriculares con cable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GPU&lt;/strong&gt;: el driver de GPU de Asahi (Honeykrisp) funciona para aceleración básica y Vulkan. Para mi flujo de desarrollo no necesito más que eso. Pero si corrés cosas de ML locales o editás video, el cuento es diferente.&lt;/p&gt;




&lt;h2&gt;
  
  
  Los errores comunes que cometí (y que vas a cometer vos también)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Asumir que el dual boot va a ser transparente
&lt;/h3&gt;

&lt;p&gt;El instalador de Asahi maneja el particionado de manera no estándar para los estándares de Apple. La primera vez que intenté reducir la partición de macOS, el proceso falló silenciosamente — sin error, sin mensaje, simplemente no cambió nada. Tuve que releer la documentación (que es buena, pero densa) para entender que necesitaba hacerlo desde el Recovery Mode de Apple con comandos específicos de &lt;code&gt;diskutil&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="c"&gt;# Esto NO funciona desde macOS normal:&lt;/span&gt;
diskutil apfs resizeContainer disk0s2 100GB

&lt;span class="c"&gt;# Esto SÍ funciona desde macOS Recovery:&lt;/span&gt;
&lt;span class="c"&gt;# diskutil apfs resizeContainer disk0s2 100GB&lt;/span&gt;
&lt;span class="c"&gt;# (mismo comando, distinto contexto — importa desde dónde corrés)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tres horas perdidas en algo que la documentación aclara, pero que yo salteé por soberbio.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Asumir que todas las imágenes Docker tienen soporte arm64
&lt;/h3&gt;

&lt;p&gt;Ya lo mencioné arriba, pero merece su propio bullet: revisá los manifiestos antes de construir un flujo que dependa de imágenes específicas. &lt;code&gt;docker manifest inspect imagen:tag&lt;/code&gt; antes de llorar a las 11pm.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Confundir "kernel support" con "feature parity"
&lt;/h3&gt;

&lt;p&gt;El soporte upstream del kernel para Apple Silicon no significa que todo lo que funciona en macOS funciona en Linux. Significa que el kernel sabe cómo hablar con el hardware base. Encima de eso hay drivers individuales, firmware, capas de userspace. Es un progreso real pero no es un flag de "terminado". Si venías con esa expectativa, vas a frustrarte.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. No hacer backup antes del primer intento
&lt;/h3&gt;

&lt;p&gt;Obvio. Lo digo igual porque yo mismo estuve tentado de no hacerlo. Hice backup. Soy adulto.&lt;/p&gt;




&lt;h2&gt;
  
  
  Mi tesis real sobre lo que significa Asahi Linux 7.0
&lt;/h2&gt;

&lt;p&gt;El driver de GPU es el titular. Pero &lt;strong&gt;la verdadera historia es que Apple Silicon dejó de ser una trampa para desarrolladores Linux&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Trampa en qué sentido: antes de Asahi, si comprabas un Mac M1/M2 y querías correr Linux en hardware real, estabas solo. Podías usar una VM, pero perdías el rendimiento del chip. Podías esperar a que alguien portara algo, pero ese alguien no era nadie con responsabilidad de mantenimiento a largo plazo. El hardware era excelente y el ecosistema Linux te decía "no sos bienvenido acá".&lt;/p&gt;

&lt;p&gt;Lo que cambió con el soporte upstream es la cadena de confianza. Ahora cuando hay un bug de kernel relacionado con Apple Silicon, hay una comunidad con incentivos para arreglarlo en el árbol principal. No en un fork que alguien abandona cuando consigue trabajo en otra parte. Eso es lo que &lt;a href="https://juanchi.dev/es/blog/bitwarden-cli-supply-chain-attack-checkmarx-superficie-confianza" rel="noopener noreferrer"&gt;aprendí con el ataque a Bitwarden CLI&lt;/a&gt;: la superficie de confianza de una herramienta no es solo su código, es su cadena de mantenimiento. Asahi mejoró esa cadena de manera estructural.&lt;/p&gt;

&lt;p&gt;Cuando migramos notas de Notion a Markdown &lt;a href="https://juanchi.dev/es/blog/migrar-notion-markdown-plain-text-lo-que-perdi" rel="noopener noreferrer"&gt;y encontramos que la portabilidad tiene un costo oculto&lt;/a&gt;, el problema no era el formato sino el lock-in de la plataforma. Apple Silicon con macOS era ese mismo problema en el lado del hardware. Comprás el mejor chip del mercado y te quedás atado al OS del fabricante. Asahi Linux 7.0 empieza a romper ese lock-in de manera legítima.&lt;/p&gt;

&lt;p&gt;¿Es para producción hoy? Para un servidor, no — no tiene sentido. Para una workstation de desarrollo donde ya sabés los gotchas y podés tolerar el Bluetooth meh y el suspend que a veces falla, &lt;strong&gt;sí, hoy mismo&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  FAQ: Asahi Linux 7.0 en Apple Silicon
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;¿Asahi Linux 7.0 es estable para uso diario?&lt;/strong&gt;&lt;br&gt;
Depende de qué entendés por "uso diario". Para desarrollo de software — compilar, correr Docker, escribir código — es estable. Para video conferencias con Bluetooth o suspender el laptop diez veces por día, todavía tiene fricción real. Mi evaluación honesta: si el trabajo consiste principalmente en terminal y browser, sí. Si necesitás todo el stack multimedia sin configuración, todavía no.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;¿Funciona Docker en Apple Silicon con Asahi Linux?&lt;/strong&gt;&lt;br&gt;
Sí. Docker corre nativamente en aarch64 sin capa de emulación. Las imágenes que tienen manifiestos &lt;code&gt;linux/arm64&lt;/code&gt; funcionan sin problemas. Las que solo tienen &lt;code&gt;linux/amd64&lt;/code&gt; van a fallar o van a necesitar emulación con &lt;code&gt;--platform&lt;/code&gt;. Revisá los manifiestos de las imágenes que usás antes de migrar el flujo completo.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;¿Qué chip de Apple funciona mejor con Asahi Linux 7.0?&lt;/strong&gt;&lt;br&gt;
M1 y M2 tienen el soporte más maduro porque llevan más tiempo bajo el microscopio de la comunidad. M3 y M4 tienen soporte creciente pero más incompleto, especialmente en drivers de GPU. Si estás eligiendo hardware nuevo pensando en Asahi, M2 Pro es el punto dulce en este momento.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;¿El driver de GPU de Asahi (Honeykrisp) sirve para desarrollo?&lt;/strong&gt;&lt;br&gt;
Para desarrollo web, sí — la aceleración básica funciona, los terminales acelerados por GPU andan bien, la experiencia de escritorio es fluida. Para workloads de ML locales o rendering 3D, el estado actual no es suficiente. Vulkan funciona pero no a la velocidad del metal nativo de Apple.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;¿Puedo usar Asahi Linux como reemplazo completo de macOS?&lt;/strong&gt;&lt;br&gt;
Hoy: parcialmente. El 80% de un flujo de desarrollo moderno funciona sin fricciones. El 20% restante — Bluetooth maduro, soporte de suspend/resume, algunas herramientas que asumen macOS — todavía necesita workarounds o sacrificios. En 12 meses, esa proporción va a cambiar. El ritmo de upstream contributions aceleró notablemente con la serie 6.12+.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;¿Vale la pena instalarlo si ya tengo un flujo macOS que funciona?&lt;/strong&gt;&lt;br&gt;
Si el flujo funciona, no lo rompas por curiosidad. Instalalo en dual boot si querés explorar sin riesgo. Lo que sí tiene sentido hacer hoy: evaluar si tu stack de desarrollo corre limpio en arm64 Linux, porque ese conocimiento va a ser relevante cuando más infraestructura migre a ARM. Yo lo hice por eso — no para escapar de macOS, sino para entender el terreno antes de que sea obligatorio entenderlo. Lo mismo que hago cuando mido &lt;a href="https://juanchi.dev/es/blog/gpt-55-api-benchmark-comparacion-casos-reales-produccion" rel="noopener noreferrer"&gt;GPT-5.5 en la API&lt;/a&gt; o evalúo &lt;a href="https://juanchi.dev/es/blog/claude-calidad-deterioro-2025-benchmarks-propios-cancelacion" rel="noopener noreferrer"&gt;si el deterioro de Claude justifica cancelar&lt;/a&gt;: no espero que el ecosistema me avise. Mido yo.&lt;/p&gt;




&lt;h2&gt;
  
  
  Conclusión: el lock-in de hardware es el problema que nadie nombra
&lt;/h2&gt;

&lt;p&gt;Pasé años preocupándome por el lock-in de plataformas de software — SaaS, herramientas, lenguajes. Lo que no estaba midiendo era el lock-in de hardware. Apple Silicon es el mejor chip del mercado de laptops hoy por hoy. El problema era que comprarlo significaba comprometerse con macOS sin salida real.&lt;/p&gt;

&lt;p&gt;Asahi Linux 7.0 no resuelve eso completamente. Pero pone la primera piedra de una salida. El soporte upstream del kernel cambia la ecuación de mantenimiento a largo plazo — y eso, para mí, pesa más que el driver de GPU. Porque los drivers mejoran con el tiempo. Lo que no mejora solo es la estructura de incentivos. Y esa estructura hoy favorece a los que quieren Linux en Apple Silicon.&lt;/p&gt;

&lt;p&gt;Lo que no compro: el hype de que "ya está listo para todos". No está. Todavía necesitás tolerancia a la incomodidad y ganas de debuggear cosas raras un sábado a la madrugada. Pero esa tolerancia siempre fue el precio de entrada al mundo Linux. Lo nuevo es que ahora hay algo del otro lado que lo justifica.&lt;/p&gt;

&lt;p&gt;Si querés arrancar: &lt;a href="https://asahilinux.org" rel="noopener noreferrer"&gt;asahilinux.org&lt;/a&gt;, leé la documentación completa antes de tocar el disco, y hacé backup. El resto es el mismo caos honesto de siempre.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Este artículo fue publicado originalmente en &lt;a href="https://juanchi.dev/es/blog/asahi-linux-70-apple-silicon-instalacion-kernel-arm" rel="noopener noreferrer"&gt;juanchi.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>spanish</category>
      <category>espanol</category>
      <category>docker</category>
      <category>linux</category>
    </item>
    <item>
      <title>An Agent Deleted My Production Database: What My Logs Say That the Viral HN Post Leaves Out</title>
      <dc:creator>Juan Torchia</dc:creator>
      <pubDate>Mon, 27 Apr 2026 12:31:57 +0000</pubDate>
      <link>https://forem.com/jtorchia/an-agent-deleted-my-production-database-what-my-logs-say-that-the-viral-hn-post-leaves-out-4ioh</link>
      <guid>https://forem.com/jtorchia/an-agent-deleted-my-production-database-what-my-logs-say-that-the-viral-hn-post-leaves-out-4ioh</guid>
      <description>&lt;h1&gt;
  
  
  An Agent Deleted My Production Database: What My Logs Say That the Viral HN Post Leaves Out
&lt;/h1&gt;

&lt;p&gt;The right solution to stop an agent from destroying production is giving it &lt;em&gt;less&lt;/em&gt; autonomy, not more guardrails. I know that sounds backwards — the entire industry is going to sell you the opposite. Let me walk through my own logs and explain why that distinction matters more than it looks.&lt;/p&gt;




&lt;p&gt;The Hacker News post blew up this week. Score 689, hundreds of comments, the obligatory thread where everyone expresses horror and then goes right back to deploying agents with the same credentials as always. I read it twice. It's a solid account of the accident. It's a terrible root cause analysis.&lt;/p&gt;

&lt;p&gt;Because the problem it describes isn't new to me. I have logs. I opened them.&lt;/p&gt;

&lt;p&gt;A few weeks back I wrote about &lt;a href="https://juanchi.dev/en/blog/crabtrap-proxy-llm-judge-agente-produccion" rel="noopener noreferrer"&gt;CrabTrap, the LLM-as-a-judge proxy I put in front of my production agent&lt;/a&gt;. Also about &lt;a href="https://juanchi.dev/en/blog/agentes-async-debugging-produccion-lo-que-no-te-dicen" rel="noopener noreferrer"&gt;async agents and what debugging doesn't tell you&lt;/a&gt;. In both cases I left something unresolved: what happens when the judge fails, when the proxy lets something through that it shouldn't. Today I want to pull on that thread.&lt;/p&gt;




&lt;h2&gt;
  
  
  What HN Says and What It Leaves Out
&lt;/h2&gt;

&lt;p&gt;The viral post has a classic structure: agent with broad permissions, ambiguous task, poorly bounded context, irreversible action. The author concludes they needed "better guardrails." The comments agree. Everyone closes the thread with a list of tools.&lt;/p&gt;

&lt;p&gt;My thesis is the opposite: &lt;strong&gt;the problem isn't that the guardrails failed. The problem is that we design for the happy path and guardrails are the patch we slap over that decision.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A guardrail is a reactive mechanism. It shows up after you've already decided to give the agent access to production, real credentials, and a scope wide enough to cause real damage. It's like installing an airbag in a car you removed the brakes from and sent downhill.&lt;/p&gt;

&lt;p&gt;What my logs show is more uncomfortable than that.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Almost Happened: Three Destructive Operations From My Own Logs
&lt;/h2&gt;

&lt;p&gt;I opened CrabTrap's logs from the last 30 days. Filtered for operations with destructive verbs: &lt;code&gt;DELETE&lt;/code&gt;, &lt;code&gt;DROP&lt;/code&gt;, &lt;code&gt;TRUNCATE&lt;/code&gt;, &lt;code&gt;rm -rf&lt;/code&gt;, reset variants. Found &lt;strong&gt;23 calls that hit the proxy&lt;/strong&gt; with some destructive intent. Of those 23:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;17 were blocked&lt;/strong&gt; by the LLM judge before executing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;4 passed the judge&lt;/strong&gt; but failed due to permission restrictions in Railway (the DB user didn't have DDL access).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2 made it all the way through&lt;/strong&gt; and executed something they shouldn't have.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those two cases are the ones that matter. Not because they were catastrophic — they weren't — but because they show exactly where the chain broke.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Case 1: DELETE with no WHERE clause&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- What the agent wanted to execute&lt;/span&gt;
&lt;span class="c1"&gt;-- Context: "clean up the test records from the staging environment"&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;sessions&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- What it should have executed&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;sessions&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;environment&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'staging'&lt;/span&gt; &lt;span class="k"&gt;AND&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;'7 days'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The proxy let &lt;code&gt;DELETE FROM sessions&lt;/code&gt; through because the judge evaluated the &lt;em&gt;intent&lt;/em&gt; as valid (clean up staging sessions) without validating that the query had no WHERE clause. The agent was right about what it wanted to do. The implementation was a disaster.&lt;/p&gt;

&lt;p&gt;The result? I wiped 14,000 session rows — production and staging mixed together because they shared the same table. Not critical — sessions are regenerable — but if that table had been &lt;code&gt;orders&lt;/code&gt; or &lt;code&gt;payments&lt;/code&gt;, this would be a very different conversation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Case 2: The Silent Cascade&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- What the agent executed in response to "delete test user id=9981"&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;users&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;9981&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- What it didn't know (and I'd never documented well):&lt;/span&gt;
&lt;span class="c1"&gt;-- users has ON DELETE CASCADE on:&lt;/span&gt;
&lt;span class="c1"&gt;--   → orders (→ order_items → inventory_movements)&lt;/span&gt;
&lt;span class="c1"&gt;--   → documents&lt;/span&gt;
&lt;span class="c1"&gt;--   → audit_logs&lt;/span&gt;
&lt;span class="c1"&gt;-- Total: 847 rows across 5 tables&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The proxy had no way to know that CASCADE existed. I hadn't documented it in the context I was passing to the agent. The judge approved the operation because it was semantically correct. The schema did the rest.&lt;/p&gt;

&lt;p&gt;This is what the HN post doesn't say: &lt;strong&gt;guardrails operate on intent, not on the side effects of your schema.&lt;/strong&gt; And schema side effects are invisible to any LLM that doesn't have the full ERD in context — which, at any real scale, is impossible.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why Framework Guardrails Are Theater
&lt;/h2&gt;

&lt;p&gt;I reviewed the guardrails offered by the three most-used agent frameworks today. Not going to name them — I'm not here to do free marketing — but the pattern is identical across all of them:&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="c1"&gt;# Typical "guardrail" pattern in agent frameworks
# (representative pseudocode, not from any specific framework)
&lt;/span&gt;
&lt;span class="n"&gt;BLOCKED_OPERATIONS&lt;/span&gt; &lt;span class="o"&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;DROP TABLE&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;TRUNCATE&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;DELETE FROM users&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;validate_query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# String matching. That's it. That's the whole thing.
&lt;/span&gt;    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;blocked&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;BLOCKED_OPERATIONS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;blocked&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;upper&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;upper&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;

&lt;span class="c1"&gt;# The problem: all of these pass cleanly
&lt;/span&gt;&lt;span class="nf"&gt;validate_query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;delete from users where id=1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# → True
&lt;/span&gt;&lt;span class="nf"&gt;validate_query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DROP   TABLE sessions&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;# → True (extra spaces)
&lt;/span&gt;&lt;span class="nf"&gt;validate_query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;EXEC sp_executesql @q&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;# → True (dynamic SQL)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;String matching on SQL queries. In 2025. With agents generating dynamic SQL from natural language context.&lt;/p&gt;

&lt;p&gt;When I built CrabTrap, I replaced that pattern with semantic evaluation — the proxy sends the query plus context to the model and asks whether the operation is destructive in that specific context. It's better. But as the two cases above show, it's still not enough when the problem is in the schema, not the query.&lt;/p&gt;

&lt;p&gt;The solution I landed on — and the one the HN post never mentions — is more boring: &lt;strong&gt;database users with minimal permissions, separated by environment, no DDL access, and row-level security where it applies.&lt;/strong&gt; Not sexy. Not a framework. It's the thing that should exist before the agent ever starts talking to your database.&lt;/p&gt;

&lt;p&gt;This connects to something I've been dragging around since the &lt;a href="https://juanchi.dev/en/blog/bitwarden-cli-supply-chain-attack-trust-surface-audit" rel="noopener noreferrer"&gt;Bitwarden CLI supply chain attack analysis&lt;/a&gt;: the trust surface gets designed before the incident, not after. When you start patching after the fact, you've already made the decisions that actually mattered.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Design Mistakes the HN Incident Normalizes Without Meaning To
&lt;/h2&gt;

&lt;p&gt;The viral post, well-intentioned as it is, lets three assumptions slide by without questioning them:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. That the agent needed direct database access.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In most cases, it doesn't. The agent should be talking to a domain API that exposes named, validated, audited operations. &lt;code&gt;deleteTestUser(id)&lt;/code&gt; instead of &lt;code&gt;DELETE FROM users WHERE id=?&lt;/code&gt;. The difference is that the domain function knows about the cascade, the domain function has business validations baked in, and the domain function can be tested against edge cases without touching production.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. That staging and production are different enough.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;They're not if they share a schema, if the agent uses the same credentials for both, or if "staging" is just a flag in an environment variable the agent can ignore or misread. I learned that the hard way with the DELETE without a WHERE clause. The agent's context is the prompt, and prompts can be incomplete or badly constructed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. That this problem is new.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It isn't. Back when I worked at a cyber café as a teenager — doing network diagnostics at 11pm with a full house — I learned something no tutorial ever teaches: systems fail at intersections, not in components. The connection didn't drop because of the router alone or the ISP alone — it dropped at the point where the two were talking past each other. Agents destroy databases at the intersection of broad autonomy, generous permissions, and incomplete context. Not from any one of those three factors alone.&lt;/p&gt;




&lt;h2&gt;
  
  
  FAQ: AI Agents and Production Databases
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What minimal permissions should an agent have when accessing a database?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Depends on the use case, but as a general rule: &lt;code&gt;SELECT&lt;/code&gt; on tables it needs to read, &lt;code&gt;INSERT&lt;/code&gt; and &lt;code&gt;UPDATE&lt;/code&gt; on tables it needs to modify, and zero DDL access (&lt;code&gt;DROP&lt;/code&gt;, &lt;code&gt;ALTER&lt;/code&gt;, &lt;code&gt;TRUNCATE&lt;/code&gt;). If the agent needs to delete data, better to expose a domain function with soft delete than direct &lt;code&gt;DELETE&lt;/code&gt; access. For production environments, row-level security is the layer that closes the perimeter when everything else fails.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Are LangChain, CrewAI, or similar guardrails enough to prevent destructive operations?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In my experience, no. They're useful as a first layer, but they operate on text patterns or semantic intent without access to the real schema. The problem of silent cascades, implicit foreign keys, or database triggers is invisible to any guardrail that doesn't have the full ERD in context. Necessary but not sufficient.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What's better: an LLM-as-a-judge proxy or restrictive DB permissions?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Both layers, in that order of priority. Restrictive permissions are the floor: they define what can physically happen. The proxy is the ceiling: it catches semantically dangerous operations before they reach the floor. If you have to pick one, pick permissions. A proxy without minimal permissions is a text guardrail sitting on top of a superuser connection.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I separate staging from production so an agent can't confuse the two?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Different database users, different credentials, and — if you can swing it — different databases on different hosts. A &lt;code&gt;ENV=staging&lt;/code&gt; flag in your config isn't enough. An agent doesn't read environment variables with the same certainty as a deterministic process: its context is the prompt, and the prompt can be incomplete or malformed. Physical separation is the only kind that can't be misinterpreted.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is it worth adding human confirmation before destructive operations?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes, but with judgment. Human-in-the-loop on every operation kills the agent's usefulness. What works is a classification system: read operations → automatic; idempotent write operations → automatic with logging; destructive or irreversible operations → human confirmation always. The trick is that classification has to live in the infrastructure layer, not in the prompt.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is the HN agent-deletes-DB incident representative of what happens in production?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;More than the industry admits. The difference between that case and mine was the permission level on the database user. In the HN case, it had full access. In mine, DDL access was blocked by Railway, which turned a potential disaster into a loggable permission error. That difference didn't come from an agent framework — it came from an infrastructure decision made before the agent existed.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I Accept, What I Don't Buy, and the Honest Trade-off
&lt;/h2&gt;

&lt;p&gt;What I accept: agents are going to keep breaking things. Not out of malice, but because they operate on incomplete context inside systems designed for humans who understand the implicit schema. That's not going to change with better prompts or better guardrails.&lt;/p&gt;

&lt;p&gt;What I don't buy: that the solution is more abstraction piled on top of the same permissions problem. Every new framework that promises "safe agents by default" and then exposes a superuser connection in the documentation examples is lying to me. I saw the same pattern with &lt;a href="https://juanchi.dev/en/blog/typescript-7-beta-real-codebase-results-what-changed" rel="noopener noreferrer"&gt;TypeScript 7.0 and its new typing features&lt;/a&gt; — new abstractions don't solve old design problems, they hide them until they explode.&lt;/p&gt;

&lt;p&gt;The honest trade-off: &lt;strong&gt;real autonomy has an infrastructure cost that most people don't want to pay.&lt;/strong&gt; Physically separating environments, creating DB users with minimal permissions, exposing domain APIs instead of direct table access, implementing soft deletes, auditing cascades — all of that takes time. It's easier to give the agent full access and trust that the LLM will do the right thing.&lt;/p&gt;

&lt;p&gt;The HN post with 689 points exists because that shortcut eventually comes due.&lt;/p&gt;

&lt;p&gt;My two near-disaster cases exist because I took shortcuts too — the DELETE without a WHERE clause was my fault in the shared schema design, the silent CASCADE was documentation I never wrote. The guardrails saved me twice. The third time might not go as well.&lt;/p&gt;

&lt;p&gt;The difference between designing for the happy path and designing for the failure path isn't a difference in tools. It's a difference in attitude toward the system. And that attitude gets learned, almost always, after something breaks.&lt;/p&gt;

&lt;p&gt;Keep the conversation going in the comments: what near-destructive operations have you found in your own logs?&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This article was originally published on &lt;a href="https://juanchi.dev/en/blog/ai-agent-deleted-production-database-logs-guardrails-real-analysis" rel="noopener noreferrer"&gt;juanchi.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>english</category>
      <category>devops</category>
      <category>postgres</category>
      <category>llm</category>
    </item>
    <item>
      <title>Un agente borró mi base de datos en producción: lo que mis logs dicen que el post viral de HN omite</title>
      <dc:creator>Juan Torchia</dc:creator>
      <pubDate>Mon, 27 Apr 2026 12:31:52 +0000</pubDate>
      <link>https://forem.com/jtorchia/un-agente-borro-mi-base-de-datos-en-produccion-lo-que-mis-logs-dicen-que-el-post-viral-de-hn-omite-3cm</link>
      <guid>https://forem.com/jtorchia/un-agente-borro-mi-base-de-datos-en-produccion-lo-que-mis-logs-dicen-que-el-post-viral-de-hn-omite-3cm</guid>
      <description>&lt;h1&gt;
  
  
  Un agente borró mi base de datos en producción: lo que mis logs dicen que el post viral de HN omite
&lt;/h1&gt;

&lt;p&gt;La solución correcta para evitar que un agente destruya producción es darle &lt;em&gt;menos&lt;/em&gt; autonomía, no más guardrails. Sé que suena raro — la industria entera te va a vender lo contrario. Dejame explicar con mis propios logs por qué esa distinción importa más de lo que parece.&lt;/p&gt;




&lt;p&gt;El post de Hacker News explotó esta semana. Score 689, cientos de comentarios, el hilo de turno donde todos se horrorizan y después siguen desplegando agentes con las mismas credenciales de siempre. Lo leí dos veces. Es un buen relato del accidente. Es un pésimo análisis de la causa raíz.&lt;/p&gt;

&lt;p&gt;Porque el problema que describe no es nuevo para mí. Tengo logs. Los abrí.&lt;/p&gt;

&lt;p&gt;Hace unas semanas escribí sobre &lt;a href="https://juanchi.dev/es/blog/crabtrap-proxy-llm-judge-agente-produccion" rel="noopener noreferrer"&gt;CrabTrap, el proxy LLM-as-a-judge que puse delante de mi agente en producción&lt;/a&gt;. También sobre los &lt;a href="https://juanchi.dev/es/blog/agentes-async-debugging-produccion-lo-que-no-te-dicen" rel="noopener noreferrer"&gt;agentes async y lo que el debugging no te dice&lt;/a&gt;. En ambos casos dejé algo sin resolver: qué pasa cuando el juez falla, cuando el proxy deja pasar algo que no debería. Hoy quiero agarrar esa hebra.&lt;/p&gt;




&lt;h2&gt;
  
  
  AI agent deleted production database: qué dice HN y qué omite
&lt;/h2&gt;

&lt;p&gt;El relato viral tiene una estructura clásica: agente con permisos amplios, tarea ambigua, contexto mal delimitado, acción irreversible. El autor concluye que necesitaba "mejores guardrails". Los comentarios coinciden. Se cierran con una lista de herramientas.&lt;/p&gt;

&lt;p&gt;Mi tesis es la opuesta: &lt;strong&gt;el problema no es que los guardrails fallaron. El problema es que diseñamos para el happy path y los guardrails son el parche sobre esa decisión&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Un guardrail es un mecanismo reactivo. Llega después de que ya decidiste darle al agente acceso a producción, credenciales reales, y un scope lo suficientemente amplio como para que pueda hacer daño. Es como poner un airbag en un auto al que le sacaste los frenos y lo mandaste cuesta abajo.&lt;/p&gt;

&lt;p&gt;Lo que mis logs muestran es más incómodo que eso.&lt;/p&gt;




&lt;h2&gt;
  
  
  Lo que casi pasó: tres operaciones destructivas en mis propios logs
&lt;/h2&gt;

&lt;p&gt;Abrí los logs de CrabTrap de los últimos 30 días. Filtré por operaciones con verbos destructivos: &lt;code&gt;DELETE&lt;/code&gt;, &lt;code&gt;DROP&lt;/code&gt;, &lt;code&gt;TRUNCATE&lt;/code&gt;, &lt;code&gt;rm -rf&lt;/code&gt;, variantes de reset. Encontré &lt;strong&gt;23 llamadas que llegaron al proxy&lt;/strong&gt; con alguna de esas intenciones. De esas 23:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;17 fueron bloqueadas&lt;/strong&gt; por el juez LLM antes de ejecutarse.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;4 pasaron el juez&lt;/strong&gt; pero fallaron por restricciones de permisos en Railway (el usuario de DB no tenía acceso a DDL).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2 pasaron todo&lt;/strong&gt; y ejecutaron algo que no debería haber ejecutado.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Esos dos casos son los que importan. No porque hayan sido catastróficos — no lo fueron — sino porque muestran exactamente dónde rompió la cadena.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Caso 1: DELETE sin WHERE&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Lo que el agente quería ejecutar&lt;/span&gt;
&lt;span class="c1"&gt;-- Contexto: "limpiá los registros de test del entorno de staging"&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;sessions&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Lo que debería haber ejecutado&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;sessions&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;environment&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'staging'&lt;/span&gt; &lt;span class="k"&gt;AND&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;'7 days'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El proxy dejó pasar el &lt;code&gt;DELETE FROM sessions&lt;/code&gt; porque el juez evaluó la &lt;em&gt;intención&lt;/em&gt; como válida (limpiar sesiones de staging) sin validar que la query no tenía cláusula WHERE. El agente tenía razón en lo que quería hacer. La implementación era un desastre.&lt;/p&gt;

&lt;p&gt;¿El resultado? Borré 14.000 filas de sesiones de producción mezcladas con las de staging porque compartían la misma tabla. No era crítico — las sesiones son regenerables — pero si esa tabla hubiera sido &lt;code&gt;orders&lt;/code&gt; o &lt;code&gt;payments&lt;/code&gt;, la conversación sería diferente.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Caso 2: Cascade silencioso&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- El agente ejecutó esto en respuesta a "eliminá el usuario de test id=9981"&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;users&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;9981&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Lo que no sabía (y yo tampoco había documentado bien):&lt;/span&gt;
&lt;span class="c1"&gt;-- users tiene ON DELETE CASCADE sobre:&lt;/span&gt;
&lt;span class="c1"&gt;--   → orders (→ order_items → inventory_movements)&lt;/span&gt;
&lt;span class="c1"&gt;--   → documents&lt;/span&gt;
&lt;span class="c1"&gt;--   → audit_logs&lt;/span&gt;
&lt;span class="c1"&gt;-- En total: 847 filas en 5 tablas&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El proxy no tenía forma de saber que ese CASCADE existía. Yo tampoco lo había documentado en el contexto que le pasé al agente. El juez aprobó la operación porque era semánticamente correcta. El schema hizo el resto.&lt;/p&gt;

&lt;p&gt;Esto es lo que el post de HN no dice: &lt;strong&gt;los guardrails operan sobre la intención, no sobre los efectos secundarios del schema&lt;/strong&gt;. Y los efectos secundarios del schema son invisibles para cualquier LLM que no tenga el ERD completo en contexto — lo cual, a escala, es imposible.&lt;/p&gt;




&lt;h2&gt;
  
  
  Por qué los guardrails de los frameworks son teatro
&lt;/h2&gt;

&lt;p&gt;Revisé los guardrails que ofrecen los tres frameworks de agentes más usados hoy. No voy a nombrarlos para no hacer publicidad gratuita, pero el patrón es el mismo en todos:&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="c1"&gt;# Patrón típico de "guardrail" en frameworks de agentes
# (pseudocódigo representativo, no de un framework específico)
&lt;/span&gt;
&lt;span class="n"&gt;BLOCKED_OPERATIONS&lt;/span&gt; &lt;span class="o"&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;DROP TABLE&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;TRUNCATE&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;DELETE FROM users&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;validate_query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# Chequeo de string matching. Eso es todo.
&lt;/span&gt;    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;blocked&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;BLOCKED_OPERATIONS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;blocked&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;upper&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;upper&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;

&lt;span class="c1"&gt;# El problema: esto pasa sin problemas
&lt;/span&gt;&lt;span class="nf"&gt;validate_query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;delete from users where id=1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# → True
&lt;/span&gt;&lt;span class="nf"&gt;validate_query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DROP   TABLE sessions&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;# → True (espacios extra)
&lt;/span&gt;&lt;span class="nf"&gt;validate_query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;EXEC sp_executesql @q&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;# → True (SQL dinámico)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;String matching sobre queries SQL. En 2025. Con agentes que generan SQL dinámico basado en contexto natural.&lt;/p&gt;

&lt;p&gt;Cuando armé CrabTrap, reemplacé ese patrón por evaluación semántica — el proxy manda la query + el contexto al modelo y pregunta si la operación es destructiva en ese contexto específico. Es mejor. Pero como muestran los dos casos de arriba, tampoco es suficiente cuando el problema está en el schema, no en la query.&lt;/p&gt;

&lt;p&gt;La solución que encontré — y que el post de HN no menciona — es más aburrida: &lt;strong&gt;usuarios de base de datos con permisos mínimos, separados por entorno, sin acceso a DDL, y con restricciones de row-level security cuando aplica&lt;/strong&gt;. No es sexy. No es un framework. Es lo que debería existir antes de que el agente empiece a hablar con la base de datos.&lt;/p&gt;

&lt;p&gt;Esto conecta con algo que vengo arrastrando desde el análisis del &lt;a href="https://juanchi.dev/es/blog/bitwarden-cli-supply-chain-attack-checkmarx-superficie-confianza" rel="noopener noreferrer"&gt;supply chain attack sobre Bitwarden CLI&lt;/a&gt;: la superficie de confianza se diseña antes del incidente, no después. Cuando empezás a remendar después, ya tomaste las decisiones que importaban.&lt;/p&gt;




&lt;h2&gt;
  
  
  Los errores de diseño que el incidente de HN normaliza sin querer
&lt;/h2&gt;

&lt;p&gt;El relato viral, aunque bien intencionado, deja pasar tres supuestos sin cuestionarlos:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Que el agente necesitaba acceso directo a la base de datos.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;En la mayoría de los casos, no lo necesita. El agente debería hablar con una API de dominio que exponga operaciones nombradas, validadas y auditadas. &lt;code&gt;eliminarUsuarioDePrueba(id)&lt;/code&gt; en vez de &lt;code&gt;DELETE FROM users WHERE id=?&lt;/code&gt;. La diferencia es que la función de dominio conoce el cascade, la función de dominio tiene validaciones de negocio, y la función de dominio puede ser testeada con casos límite sin tocar producción.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Que los entornos de staging y producción son lo suficientemente distintos.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No lo son si comparten schema, si el agente usa las mismas credenciales, o si "staging" es simplemente un flag en una variable de entorno que el agente puede ignorar o malinterpretar. Yo lo aprendí a las malas con el caso del DELETE sin WHERE.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Que el problema es nuevo.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No lo es. En el cyber café donde laburé de adolescente, el diagnóstico de red a las 11pm con el local lleno me enseñó algo que ningún tutorial explica: los sistemas fallan en las intersecciones, no en los componentes. La conexión no se caía por el router solo ni por el ISP solo — se caía en el punto donde los dos se hablaban mal. Los agentes destruyen DBs en la intersección entre autonomía amplia, permisos generosos y contexto incompleto. No en ninguno de esos tres factores solos.&lt;/p&gt;




&lt;h2&gt;
  
  
  FAQ: AI agents y bases de datos en producción
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;¿Qué permisos mínimos debería tener un agente que accede a una base de datos?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Depende del caso de uso, pero como regla general: &lt;code&gt;SELECT&lt;/code&gt; sobre las tablas que necesita leer, &lt;code&gt;INSERT&lt;/code&gt; y &lt;code&gt;UPDATE&lt;/code&gt; sobre las tablas que necesita modificar, y cero acceso a DDL (&lt;code&gt;DROP&lt;/code&gt;, &lt;code&gt;ALTER&lt;/code&gt;, &lt;code&gt;TRUNCATE&lt;/code&gt;). Si el agente necesita borrar datos, mejor exponerle una función de dominio con soft delete que acceso directo a &lt;code&gt;DELETE&lt;/code&gt;. Para entornos productivos, row-level security es la capa que cierra el perímetro cuando el resto falla.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;¿Los guardrails de LangChain, CrewAI o similares son suficientes para prevenir operaciones destructivas?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;En mi experiencia, no. Son útiles como primera capa, pero operan sobre patrones de texto o sobre intención semántica sin acceso al schema real. El problema de los cascades silenciosos, las foreign keys implícitas o los triggers de base de datos es invisible para cualquier guardrail que no tenga el ERD completo en contexto. Son necesarios pero no suficientes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;¿Qué es mejor: un proxy LLM-as-a-judge o permisos restrictivos en la DB?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Las dos capas, en ese orden de prioridad. Los permisos restrictivos son el piso: definen qué puede pasar físicamente. El proxy es el techo: detecta operaciones semánticamente peligrosas antes de que lleguen al piso. Si tenés que elegir uno, elegí los permisos. El proxy sin permisos mínimos es un guardrail de texto sobre una conexión con permisos de superusuario.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;¿Cómo separo staging de producción para que un agente no pueda confundir los dos?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Usuarios de base de datos distintos, credenciales distintas, y — si podés — bases de datos distintas en hosts distintos. No alcanza con un flag &lt;code&gt;ENV=staging&lt;/code&gt; en la configuración. El agente no lee variables de entorno con el mismo nivel de certeza que un proceso determinístico: su contexto es el prompt, y el prompt puede estar incompleto o mal construido. La separación física es la única que no puede ser malinterpretada.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;¿Vale la pena agregar confirmación humana antes de operaciones destructivas?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Sí, pero con criterio. Human-in-the-loop en cada operación destruye la utilidad del agente. Lo que funciona es un sistema de clasificación: operaciones de lectura → automático; operaciones de escritura idempotentes → automático con log; operaciones destructivas o irreversibles → confirmación humana siempre. El truco es que esa clasificación tiene que estar en la capa de infraestructura, no en el prompt.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;¿El problema del agente que borró la DB en HN es representativo de lo que pasa en producción?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Más de lo que la industria admite. La diferencia entre ese caso y los míos fue el nivel de permisos que tenía el usuario de base de datos. En el caso de HN, tenía acceso total. En el mío, el acceso DDL estaba bloqueado por Railway, lo que convirtió un potencial desastre en un error de permisos logueable. Esa diferencia no vino de un framework de agentes — vino de una decisión de infraestructura tomada antes de que el agente existiera.&lt;/p&gt;




&lt;h2&gt;
  
  
  Lo que acepto, lo que no compro y el trade-off honesto
&lt;/h2&gt;

&lt;p&gt;Lo que acepto: los agentes van a seguir rompiendo cosas. No por malicia, sino porque operan sobre contexto incompleto en sistemas diseñados para humanos que entienden el schema implícito. Eso no va a cambiar con mejores prompts ni con mejores guardrails.&lt;/p&gt;

&lt;p&gt;Lo que no compro: que la solución es más abstracción encima del mismo problema de permisos. Cada framework nuevo que promete "agentes seguros por defecto" y después expone una conexión con credenciales de superusuario en los ejemplos de la documentación me está mintiendo. Lo vi con &lt;a href="https://juanchi.dev/es/blog/typescript-70-beta-novedades-prueba-codebase-real" rel="noopener noreferrer"&gt;TypeScript 7.0 y sus nuevas features de tipado&lt;/a&gt; — las abstracciones nuevas no resuelven los problemas de diseño viejos, los ocultan hasta que explotan.&lt;/p&gt;

&lt;p&gt;El trade-off honesto: &lt;strong&gt;autonomía real tiene un costo de infraestructura que la mayoría no quiere pagar&lt;/strong&gt;. Separar entornos físicamente, crear usuarios de DB con permisos mínimos, exponer APIs de dominio en vez de acceso directo a tablas, implementar soft deletes, auditar cascades — todo eso toma tiempo. Es más fácil dejar al agente con acceso completo y confiar en que el LLM va a hacer lo correcto.&lt;/p&gt;

&lt;p&gt;El post de HN con 689 puntos existe porque ese atajo eventualmente cobra.&lt;/p&gt;

&lt;p&gt;Mis dos casos casi-desastre existen porque yo también tomé atajos — el DELETE sin WHERE fue descuido mío en el diseño del schema compartido, el CASCADE silencioso fue documentación que nunca escribí. Los guardrails me salvaron dos veces. La tercera podría no hacerlo.&lt;/p&gt;

&lt;p&gt;La diferencia entre diseñar para el happy path y diseñar para el failure path no es una diferencia de herramientas. Es una diferencia de actitud frente al sistema. Y esa actitud se aprende, casi siempre, después de que algo se rompe.&lt;/p&gt;

&lt;p&gt;Seguí la conversación en los comentarios: ¿qué operaciones casi-destructivas encontraste en tus propios logs?&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Este artículo fue publicado originalmente en &lt;a href="https://juanchi.dev/es/blog/agente-ia-borro-base-datos-produccion-logs-guardrails" rel="noopener noreferrer"&gt;juanchi.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>spanish</category>
      <category>espanol</category>
      <category>devops</category>
      <category>postgres</category>
    </item>
  </channel>
</rss>
