DEV Community

MORINAGA
MORINAGA

Posted on

Cloudflare Pages returned HTTP 500 on every page that contained m.do.co

I spent the afternoon wiring affiliate CTAs into the three SEO directory sites I run. The plan was unglamorous: env-gated <section> blocks on detail pages, one referral provider per site, and a Cloudflare API script to push the IDs into all three projects at once.

The implementation was 60 lines of TypeScript. Then ossfind.com started returning HTTP 500 on every detail page.

Not 404. Not a build failure. Cloudflare Pages happily logged "deploy/success" and the homepage rendered fine. But /alternatives/1password/, /alternatives/airtable/, and 78 other detail pages all returned HTTP 500 with content-length: 0 — the response body was empty.

Local builds were producing valid 23 KB HTML files. The build log showed all 321 files uploaded. A sibling project with the exact same component pattern was working.

This is the bisect that found it.

What I was building

Three sites — aiappdex.com, findindiegame.com, ossfind.com — share a packages/shared workspace with monetization helpers. Today I added env-driven referral URL builders:

export function digitalOceanReferralUrl(ref: string | null): string | null {
  if (!ref) return null;
  return `https://m.do.co/c/${encodeURIComponent(ref)}`;
}
Enter fullscreen mode Exit fullscreen mode

Plus a sidebar block on each site's detail page:

const aff = getAffiliateConfig();
const hostingProviders = [
  { label: "DigitalOcean", note: "$200 free credit", url: digitalOceanReferralUrl(aff.digitaloceanRef) },
  { label: "Hetzner Cloud", note: "€20 credit", url: hetznerReferralUrl(aff.hetznerRef) },
].filter((p) => p.url !== null);

{hostingProviders.length > 0 && (
  <section>
    <h2>Self-host on</h2>
    <ul>
      {hostingProviders.map((p) => (
        <li><a href={p.url} rel="sponsored noopener nofollow">{p.label}</a></li>
      ))}
    </ul>
  </section>
)}
Enter fullscreen mode Exit fullscreen mode

When the env var is empty, the section doesn't render. When set, it renders one anchor per provider. Standard conditional Astro.

I shipped the code, set PUBLIC_DIGITALOCEAN_REF=<my-referral-code> on the Cloudflare Pages project via the API, triggered a redeploy. Build succeeded. Site broke.

The first wrong theory

My initial assumption was: "I broke the build." This is almost always the right first guess. So I checked the obvious.

$ pnpm --filter @seo-farm/oss-alternatives build
...
[build] generating static routes
✓ Completed in 0.142s
Enter fullscreen mode Exit fullscreen mode

Local build clean. I opened the generated HTML for 1password.html:

$ wc -c apps/oss-alternatives/dist/alternatives/1password/index.html
23054
Enter fullscreen mode Exit fullscreen mode

23 KB, valid <!DOCTYPE html>, closing </html>, no encoding glitches. The new section was there with the correct DigitalOcean URL embedded.

Then I checked Cloudflare's deployment metadata:

files_count: 321  (matches local)
latest_stage: deploy/success
Enter fullscreen mode Exit fullscreen mode

Same files locally and remotely. Build successful. Site 500ing.

The bisect

Cloudflare Pages keeps every past deployment available at a hashed subdomain. So I tested HTTP status against the last 10 deployments:

for id in 926e6bcd fd211f41 10889b10 83278518 abac1b99 0818aa3a; do
  s=$(curl -sI "https://${id}.ossfind.pages.dev/alternatives/1password/" \
      | head -1 | awk '{print $2}')
  echo "$id$s"
done
Enter fullscreen mode Exit fullscreen mode
926e6bcd → 500   ← latest
fd211f41 → 500
10889b10 → 200
83278518 → 200   ← last good
abac1b99 → 200
0818aa3a → 200
Enter fullscreen mode Exit fullscreen mode

The break started at fd211f41. I diffed the commits: 83278518 (good) and fd211f41 (broken) were both empty redeploy commits. Same code. The only thing that had changed between the two builds was that I'd set PUBLIC_DIGITALOCEAN_REF on the Cloudflare Pages env between them.

Confirmation step: I deleted PUBLIC_DIGITALOCEAN_REF from the Cloudflare API and redeployed. HTTP 200. Re-added it: HTTP 500.

So the env var triggers the break. Which means something Cloudflare does at serve time sees the rendered output containing the URL https://m.do.co/c/<code> and chokes on it.

The fix

The code path is identical for all hosting providers. So if m.do.co was the trigger, swapping to a different DigitalOcean URL form should also work. DigitalOcean accepts both:

  • Short form: https://m.do.co/c/<refcode> (the link m.do.co page hands you)
  • Canonical form: https://www.digitalocean.com/?refcode=<refcode>

Both track to the same referral. I swapped the helper:

export function digitalOceanReferralUrl(ref: string | null): string | null {
  if (!ref) return null;
  return `https://www.digitalocean.com/?refcode=${encodeURIComponent(ref)}`;
}
Enter fullscreen mode Exit fullscreen mode

Pushed. Re-set the env var. Redeployed.

HTTP/2 200
Self-host on
www.digitalocean.com/?refcode=<code>
Enter fullscreen mode Exit fullscreen mode

All 80 detail pages back to 200. CTA rendered. Affiliate tracking intact.

Why this happens (best guess)

Cloudflare doesn't document this behavior, and the response body was empty so I have no error message to anchor on. But the pattern fits a content-scanning layer that flags certain shortlink domains. m.do.co is a redirect domain — it 301s to digitalocean.com with the referral query param attached. Cloudflare runs the broader Cloudflare network's URL reputation infrastructure, and short-redirect domains are exactly the shape of thing abusers use for cloaking.

The homepage didn't contain the URL, so it served. Detail pages did, so they didn't.

I can't prove the scanning theory without internal access. What I can prove: removing the URL fixed it, swapping to the canonical domain fixed it, every other affiliate URL on every other page (RunPod's ?ref=, Vast.ai's ?ref_id=, the Amazon search URLs) renders fine.

Takeaways

  1. A Cloudflare Pages "deploy success" is not the same as "Cloudflare will serve this page." The build pipeline and the edge serving layer have separate failure modes. If you see 500s with empty bodies after a deploy that logged success, suspect the serve layer, not the build.

  2. Avoid URL shorteners in static HTML you serve from Cloudflare Pages. Affiliate networks usually offer both shortlink and canonical-domain forms for the same destination. Pick the canonical one.

  3. Hashed-subdomain previews are the bisect tool you need. Every past deployment stays addressable at <hash>.<project>.pages.dev. Curling each one in a loop turns "when did this break" into a thirty-second answer.

  4. An empty 500 body is signal, not noise. It means the request didn't reach origin processing — something rejected it before. Static origins don't 500 on their own; the platform did.

The end-state is three SEO sites with their first non-AdSense revenue channels live: DigitalOcean on ossfind, RunPod and Vast.ai on aiappdex, with Humble Bundle and Fanatical pending approval for findindiegame. Total time, including the rabbit hole: about three hours, of which forty minutes was the m.do.co bisect.

Worth saving the canonical-URL rule somewhere durable. I added it to my project memory, which is why this article exists — when the next person on the team hits "all detail pages 500 with empty body, deploy logged success, build is fine," I want them to find this in two minutes instead of forty.

Top comments (0)