← All notes

Why a server without a public IP can still have its own domain

·4 min read Indonesia

I had a small machine in the cloud. Four vCPUs, 8 GB, Debian, nginx listening on port 8080. It served a static site perfectly well through the address my provider handed me.

I also had a domain sitting unused. The question seemed trivial: how do you connect the two?

The naive answer is to point the domain at the machine's IP. What I found instead were three walls.

Three reasons the straight path is blocked

First, there is no address to point at. The machine reaches the internet through a shared exit, and nothing can reach it from outside. The IP you see in the logs is the exit door, not the machine. There is nothing you can put in a DNS record.

Second, the HTTPS certificate is not yours. The provider's ingress holds a certificate for their own domain. Point your domain there and the browser refuses the connection, because the name on the certificate does not match. No CNAME trick fixes this.

Third, and this is the part nobody warns you about: your cache headers get rewritten. nginx inside the machine sends Cache-Control correctly, but the provider's ingress replaces it with no-store — do not store anything. Every visit re-downloads the whole page, and every request spends roughly 850 ms just reaching the machine.

That third wall is what changed how I thought about the problem. It was no longer "how do I connect a domain", but "how do I put something in front of this machine that can remember an answer".

The most sophisticated answer was the wrong one

My first instinct was Cloudflare Tunnel. It sounds perfect: run one program inside the machine, open a tunnel to Cloudflare, and your domain works with no public IP at all.

I set it up, and it worked. Until I noticed something.

A tunnel is a process that has to stay alive. My machine pauses itself after 30 minutes of inactivity. The moment it sleeps, the tunnel drops and Cloudflare returns 502. Worse: requests through the tunnel never touch the provider's ingress, so nothing in the system can wake the machine back up.

The solution collides directly with how the platform works. Not a missing configuration — a wrong concept from the start.

The lesson: the most sophisticated solution is not automatically the right one. What matters is whether it agrees with the constraints you actually have.

What I used instead: a small program at the edge

A Cloudflare Worker is a program that runs on Cloudflare's network, not on my machine. It can receive a request, decide what to do, and keep a copy of the answer.

The core of it is this simple:

const res = await fetch(ORIGIN + url.pathname);

But three details make it work:

  • A request to the provider's public URL wakes the machine. So the Worker does not merely forward; it is the thing pressing the wake button.
  • The Worker keeps its own copy through the Cache API, outside the reach of the forced no-store header.
  • If the machine is down, the old copy is still served. Visitors get a page, not an error.

The result: the first request takes 1,944 ms because the machine has to wake. Every request after that takes 1 ms, and the machine is free to stay asleep.

Two things I only learned once it was live

Every local test passed. Then the site went live, and two problems appeared that no local test could have caught.

My site was blocked from Google. The provider's ingress attaches X-Robots-Tag: noindex, nofollow to every response. Not from my nginx, not from Cloudflare. So even though robots.txt allowed crawling, Google was still forbidden. The fix: the Worker strips that header.

Cloudflare cached the response itself and locked it for four hours. The max-age I sent was rewritten to 14,400 seconds. Worse, that copy lives in Cloudflare's edge cache, and ?fresh=1 cannot invalidate it because the URL is considered different. The fix: the Worker sends no-store to the client, so the only cache is the Worker's own — which I control completely.

Both were found by inspecting headers from the real domain, not from localhost.

The final numbers

MetricBeforeAfter
Time to first byte1,944 ms70–82 ms
First Contentful Paint1,924–2,832 ms108–124 ms
Indexablenoyes
Cost—zero

The cost is zero because Cloudflare's free plan allows 100,000 requests per day, and the provider's free plan includes 100 GiB of egress per month. For a site this size, both are far more than enough.

What I took away

A platform's constraints are not obstacles to fight. They are the problem statement, written in a different form.

As long as I framed it as "how do I connect a domain to a machine", every path was blocked. The moment it became "how do I put something in front of the machine that can remember an answer", the solution was almost trivial — and it happened to fix the speed problem at the same time.

One more thing: nothing replaces checking the real thing. Those last two bugs would never have appeared locally, because both were created by a layer that does not exist locally.