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.
/*
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.
- 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.
- 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.
- 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.
| Rule | Covers | Wins when |
|---|---|---|
| Explicit address | chris@chrismcgowan.dev | Always — explicit rules are matched before the catch-all. |
| Catch-all | *@chrismcgowan.dev | Only when no explicit rule matched. |
| Subdomain | nothing, until you add it | Never — see below. |
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.
- 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.
- Send two messages: one to the real address, one to an invented local part. Both should arrive.
- 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.
| Status | What it means |
|---|---|
Forwarded | The only good answer. It reached the destination address. |
Dropped | A rule or filter binned it — check the catch-all's action, and whether a rule set to Drop is matching first. |
Rejected | SPF, DKIM or DMARC failed at ingress. The domain's DNS, not the rule. |
Delivery failed | Cloudflare forwarded it and the destination mailbox refused it. |
Error | Internal. Try again before believing it. |
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
- 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.
- Write the
_headersfile 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. - 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.
- 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 →