Machine Room github

Domain · mail

A domain, a site,
and an inbox

One HTML file served from Cloudflare Pages, and mail for the domain that never becomes a mailbox. The useful part is the small print: a catch-all that does not cover subdomains, and how to prove the routing works instead of assuming it.

The whole point of this site is that it is one file. No framework, no build step, no dependency that will be unmaintained in three years. That constraint decides the hosting too, and it turns out the same company that serves the file can also receive the mail for it — which is where the interesting exception lives.

What the domain is for

Three jobs, in order of how often they are used: serve a few pages, receive mail at a stable address, and stay cheap enough that renewing it does not require justifying it. Nothing here needs a server, a container, or a database, and the moment any of those appears the project has grown beyond its reason for existing.

The site is a single index.html of about 130 KB with the CSS, the JavaScript and the icon inside it, and hash routes so that every page is the same file. That last decision is the one that pays off at deploy time: there is no routing table to configure on the host, because there is nothing to route.

Serving the file

Cloudflare Pages takes the directory and serves it. Two ways in, and the difference matters more than it looks:

  • Direct upload. Drag the files into the dashboard. Fastest possible start, no repository, and every future change is another drag. Fine for one file; the history of what changed lives in your head.
  • Connected to a git repository. Push, and Pages rebuilds. The build command is none and the output directory is /, because there is nothing to build and nothing to generate. What you get for the extra setup is a deploy history and preview URLs for changes.

Either way, attaching the custom domain is a dashboard step and Cloudflare creates the DNS records itself. Do not hand-create a CNAME for the apex and then wonder why it flattens oddly; the platform's own binding is the supported path. HTTPS is included, which matters for one reason beyond hygiene: the only third-party request the page makes is for fonts, and that request should not be the one thing a browser warns about.

_headers — the only server configuration in the project
/*
  X-Content-Type-Options: nosniff
  X-Frame-Options: DENY
  Referrer-Policy: strict-origin-when-cross-origin
  Permissions-Policy: geolocation=(), camera=(), microphone=()
  Content-Security-Policy: default-src 'self'; base-uri 'none';
    form-action 'none'; img-src 'self' data:;
    style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
    font-src https://fonts.gstatic.com; script-src 'self' 'unsafe-inline'

/index.html
  Cache-Control: public, max-age=300, must-revalidate

A static host gives you no place to put a header, which is exactly why the _headers file exists — four lines of policy that a server-rendered site would have configured in nginx and that a static site otherwise silently omits. The CSP is the interesting one: everything is 'self' except the two font hosts, so a compromised dependency has nowhere to send data even if one appeared.

An inbox that is not a mailbox

Email Routing does not host a mailbox. It receives mail at your domain and forwards it to a mailbox that already exists somewhere else. That is a much smaller promise than a mail service, and for a personal domain it is the right one: nothing to administer, nothing to store, no second place where mail can go missing.

  1. Onboard the domain. Cloudflare adds the MX records pointing at its routing servers, an SPF TXT record authorising the forwarding, and a DKIM TXT record so forwarded mail is authenticated. Let it create all three.
  2. Add a destination address. This is a real mailbox you already own, and it must be verified by clicking a link before any rule can use it. An unverified destination leaves rules created but disabled, which reads as broken forwarding when nothing is broken.
  3. Create the routing rule. Local part, domain, action, destination.

The distinction between the two halves is worth holding onto. The domain is onboarded once; the destination lives at account level and can be reused by every domain you own. Ten domains, one inbox, ten rules.

The catch-all, and the one thing it misses

A catch-all forwards everything sent to the domain, including local parts you never created. It is the difference between "my address is chris@" and "any of these addresses work", and it absorbs the typo class of failure entirely: a sender who types the domain wrong, or capitalises the surname in the middle, still reaches you.

RuleCoversWins when
Explicit addresschris@chrismcgowan.devAlways — explicit rules are matched before the catch-all.
Catch-all*@chrismcgowan.devOnly when no explicit rule matched.
Subdomainnothing, until you add itNever — see below.
Precedence is not a matter of row order in the dashboard; matching is by specificity.

The exception is the one people get caught by: catch-all works on the apex domain only. *@chrismcgowan.dev does not cover anything@mail.chrismcgowan.dev. A subdomain needs its own MX records and its own literal routing rule, and until both exist, mail to that subdomain does not fail loudly — it fails as a bounce the sender may never report.

Before handing out a subdomain address

If you plan to give out school@mail.example.dev or anything else on a subdomain, decide that deliberately: publish the subdomain's MX records, add a literal rule for each address, and test one. The catch-all will not quietly cover it, and the failure mode is silence.

Two smaller pieces of the same small print. Subaddressing — chris+anything@ — is a separate toggle under Settings, not part of the catch-all; with it enabled, the +detail matches the chris rule and is preserved in the message headers, which makes it a usable tagging mechanism for signups. And routing is inbound only: replies come from the mailbox you actually read unless you separately configure send-as, so a message sent from the forwarding inbox will not appear to come from the domain.

Confirm it actually routes

Configuration that has never been tested is a hypothesis. This takes two messages and one look at a log, and it is worth doing while the settings are still fresh in mind — especially because both rules point at the same mailbox here, which means a broken catch-all would be invisible until the day it mattered.

  1. Send from a different account than the destination. Several providers discard mail that appears to loop back to itself, and that failure looks exactly like a routing bug.
  2. Send two messages: one to the real address, one to an invented local part. Both should arrive.
  3. Read the Activity log under Email Routing. It can be filtered from thirty minutes to thirty days, and every message that reached Cloudflare leaves a row with a status.
StatusWhat it means
ForwardedThe only good answer. It reached the destination address.
DroppedA rule or filter binned it — check the catch-all's action, and whether a rule set to Drop is matching first.
RejectedSPF, DKIM or DMARC failed at ingress. The domain's DNS, not the rule.
Delivery failedCloudflare forwarded it and the destination mailbox refused it.
ErrorInternal. Try again before believing it.
Expand a row to see the SPF, DKIM and DMARC results for that individual message.

One honest limitation: the log shows per-message status and authentication, not which rule matched. So it proves delivery, never precedence. Precedence only becomes observable the day the explicit rule and the catch-all point at different destinations — and the way to check it then is simply that chris@ still arrives while an invented address goes wherever the catch-all sends it.

What it costs, and what it will not do

Inbound routing is unlimited on the free plan, and that is the whole of what is needed here. Outbound sending to arbitrary recipients is a different product on a paid plan; sending only to your own verified destination addresses is free on every plan. For a personal site that distinction means the mail half of this setup never appears on a bill.

What it will not do, stated plainly so it is not discovered later: it will not give you a mailbox you can log into, it will not send mail as your domain without extra configuration, and it will not filter the spam that a catch-all invites. Every invented local part that reaches the domain is forwarded, and your destination provider's filter is the only thing standing between that and your inbox. When the volume becomes annoying, toggle the catch-all to Disabled — the explicit rules keep working, and nothing else breaks.

What I would do differently

  1. Connect the repository on day one. Uploading a file is instant right up to the moment you need to know what changed four edits ago.
  2. Write the _headers file before publishing, not after. A static host will happily serve a page with no policy at all, and retrofitting a CSP means re-testing everything that loads.
  3. Test the invented address, not just the real one. The real address works for a reason that has nothing to do with the catch-all, which is exactly why it proves nothing about it.
  4. Decide about subdomains before promising anyone an address on one. The apex-only rule is easy to read and easy to forget at the moment you hand out a mailbox.

The site is the easy half. The mail is the half that fails quietly.

Back to the projects →