Free

Let Claude scrub your origin IP off public DNS

The origin IP behind Cloudflare leaks through the deploy subdomain — three repos, fourteen seconds, fixed in one sweep


I stared at GitHub for a moment that afternoon. Three repos closed PRs at 16:20:31, 16:20:38, 16:20:45 — fourteen seconds apart.

  • smarts #38: deploy.smarts.mddeploy.smarts
  • how2claude #13: deploy.how2claude.comdeploy.how2claude
  • pickful #118: deploy.pickful.ai / deploy.pickful.xyzdeploy.pickful / deploy.pickful-alpha

All three fixes were the same class of bug. I had been chatting with Claude about an unrelated deploy quirk in how2claude. It glanced at config/deploy.yml and told me, in passing, that the origin IP of that machine was sitting in plain sight on the public internet.

A misconception that had fooled me for months

All my projects sit behind Cloudflare. The little orange cloud in the dashboard means the record is proxied — HTTP requests hit a Cloudflare edge node first, then my origin. The origin IP never appears in DNS responses; what DNS returns is a Cloudflare anycast IP. That part was fine.

But I deploy with Kamal. Kamal SSHes into the server to run docker. SSH cannot go through an HTTP proxy, so I needed a hostname not proxied by Cloudflare to SSH into. My setup was:

# config/deploy.yml
servers:
  web:
    - deploy.how2claude.com   # ← A record straight to the origin IP, grey cloud (DNS only)

In Cloudflare, how2claude.com and www.how2claude.com were orange. deploy.how2claude.com was grey. It had to be grey, otherwise SSH couldn't reach the box.

Grey means public DNS. Anyone can run:

$ dig deploy.how2claude.com +short
<my origin IP>

And the naming convention deploy.<domain> is itself a tell — script the deploy.* subdomain across a list of common SaaS domains and you have a harvest of origin IPs that were supposed to be hiding behind Cloudflare.

Bypass the Cloudflare WAF, bypass rate limiting, bypass DDoS protection. One dig away.

Why Claude noticed

We were talking about something else. I asked it to read a deployment doc I had just written; it opened config/deploy.yml to cross-check, got to line 7, - deploy.how2claude.com, paused, and said something like:

That hostname resolves over public DNS, right? Which means anyone running a DNS query gets that origin IP, and Cloudflare's edge proxy is bypassed.

I sat there for a second. I should have seen this — I had every reason to clock the grey cloud icon when configuring Cloudflare and ask what it actually meant — but I hadn't. I had treated the hostname as somehow internal, purely because the prefix was deploy.. My brain quietly granted it a privacy it never had.

Claude doesn't share that bias. It read a string in a yaml field and asked the mechanical question: how does this string become an IP? Answer: public DNS. Conclusion: the origin IP is public.

The fix: a /etc/hosts alias

The fix turned out to be embarrassingly simple. Kamal asks the local SSH client to resolve the hostname, so the hostname only has to resolve on my machine — not for the entire internet.

Shorten the hostname in yaml:

servers:
  web:
    - deploy.how2claude    # ← note: no .com

Then add a line to my /etc/hosts:

198.51.100.42  deploy.how2claude

CI runners that deploy need the same line (an env var, or just echo >> /etc/hosts in the workflow).

Result:

  • No deploy.how2claude.* record exists in public DNS anywhere
  • Attackers can't pull the IP via DNS — at least that path is closed
  • Every kamal deploy / app exec / app logs keeps working, because /etc/hosts is consulted before DNS
  • Cloudflare gets cleaner: that awkward grey-cloud subdomain just goes away

The database accessory needs the same change:

accessories:
  db:
    image: postgres:17
    host: deploy.how2claude   # ← same hostname

Claude carried this across all three repos

This is the part I wanted to write down.

After we shipped the how2claude fix, I was about to switch contexts. Claude stopped me: "You also use Kamal for smarts and pickful, right? Let me check those."

It did.

  • smarts: deploy.smarts.md — same problem, public A record
  • pickful: deploy.pickful.ai (production), deploy.pickful.xyz (alpha), plus a long-dead deploy-test1.pickful.ai and a deploy.staging.yml that referenced a retired blockgeek.com domain

Pickful was the messiest of the three because it had multiple destinations (production / alpha / test2) and historical baggage. Claude swept the dead staging and test1 destinations while it was there — they had no reason to exist anymore, just noise.

Final commits:

smarts       6482472   2026-04-27 16:20:31  Use private deploy.smarts alias instead of public deploy.smarts.md (#38)
how2claude   ccc0344   2026-04-27 16:20:38  Use private deploy.how2claude alias instead of public deploy.how2claude.com (#13)
pickful      e6bf9af   2026-04-27 16:20:45  Deploy config hygiene + privatize hostnames (#118)

Fourteen seconds. Of course that's just GitHub flushing a merge queue — the actual work was spread over the previous two hours. But the effective shape was: one finding, three repos fixed.

If I had been working alone, the realistic version is: fix it in how2claude, write a TODO that says "do smarts and pickful too", and watch that TODO sit in the list for three months. I have done that exact thing many times.

A checklist you can copy

If you deploy with Kamal, Capistrano, or any SSH-based deploy tool:

  1. Open config/deploy*.yml and grep for host: and servers:. List every hostname that appears.
  2. Run dig +short on each one. Anything that returns an origin IP rather than nothing is leaking.
  3. Walk through your CDN dashboard and check for grey-cloud subdomains — those are the candidate set.
  4. Rename them to a TLD-less alias (deploy.<project>), and put the IP into /etc/hosts.
  5. Update your CI deploy job too. On GitHub Actions, that's echo "$IP deploy.<project>" | sudo tee -a /etc/hosts.
  6. Delete the original public A record.

If your team owns several projects sharing one infrastructure pattern, write a one-liner that greps every repo's deploy*.yml rather than checking them one by one.

The actual lesson

Cloudflare didn't fail. Kamal didn't fail. Both tools were doing exactly what they were supposed to do.

The lesson is: default values quietly mislead you. deploy.<your-domain>.com sounds like an internal name, but DNS has no concept of "internal" — an A record is an A record, and once it's published it's public. A name can give you the illusion of privacy, and that little grey cloud icon in Cloudflare's dashboard is just the visual form of the same illusion: it sits there quietly, no red warnings, but what it is telling you is "this record does not pass through me".

Let Claude read your deploy config once. It does not share the illusion.