Personal Site Infrastructure


  • Description: End-to-end personal-site infrastructure on a custom domain — Docker for local dev, GitHub + Vercel for deployment, Cloudflare for DNS, free domain email (Cloudflare Email Routing + Gmail Send-as), and the SPF/DKIM/DMARC deliverability triad.
  • My Notion Note ID: K2B-8-1
  • Created: 2026-02-22
  • Updated: 2026-05-17
  • License: Reuse is very welcome. Please credit Yu Zhang and link back to the original on yuzhang.io

Table of Contents


1. Overview

Two parallel pipelines on a single custom domain:

       local (Docker)
              │
              │ push
              ▼
           GitHub
              │
              │ build
              ▼
           Vercel
              │
              │ A / CNAME
              ▼
       Cloudflare DNS ──HTTPS──▶ https://www.example.io
              │
              │ MX
              ▼
   Cloudflare Email Routing
              │
              │ forwards
              ▼
     [email protected]
              │
              │ Send As via Gmail SMTP
              ▼
          recipient

End state:

  • Next.js project running locally in a container, no Node installed on the host.
  • GitHub repo as canonical source.
  • Vercel auto-deploys every main push; free HTTPS + global CDN.
  • Custom domain bound, TLS issued automatically.
  • [email protected] inbox forwarded to Gmail; outbound mail sent through Gmail SMTP with From: [email protected].
  • SPF and DMARC TXT records published; DKIM aligned wherever the sending platform supports it.
  • One reproducible playbook for any future personal site / blog / portfolio.

2. Why This Stack

Component Why
Next.js Native server/client rendering, file-system routing, mature ecosystem. Great fit for "static content + small dashboard" sites.
Vercel Zero-config Next.js deploys, preview URLs per branch, HTTPS + CDN included, generous free tier.
Docker Old host glibc → host Node refuses to run. Container isolates the runtime so the host stays untouched.
GitHub De facto remote, plays well with Vercel's auto-deploy.
Cloudflare DNS Free DNS hosting, fast propagation, sensible UI. Domain can be registered elsewhere.
Cloudflare Email Routing Free MX-based forwarding for custom-domain inbound mail.
Gmail Send-as Free SMTP send-from for outbound mail using the custom address.

3. Prerequisites

  • Docker installed and working — docker version returns without error.
  • GitHub account.
  • Vercel account (sign up with GitHub for easiest integration).
  • Cloudflare account (domain can stay at the original registrar; just delegate DNS to Cloudflare nameservers).
  • A personal Gmail account with 2-Step Verification enabled (needed for App Passwords later).

4. Bootstrapping Next.js Inside Docker

In an empty target directory:

docker run --rm -it \
  -v "$PWD":/app \
  -w /app \
  node:22-bookworm \
  bash -lc "corepack enable && corepack prepare pnpm@latest --activate && pnpm create next-app@latest ."

What this does:

  • --rm — delete container after exit.
  • -it — interactive (Next.js scaffolder asks questions).
  • -v "$PWD":/app — mount current directory into the container so generated files land on the host.
  • -w /app — start in /app inside the container.
  • node:22-bookworm — Node 22 LTS on Debian Bookworm.
  • corepack enable && corepack prepare pnpm@latest --activate — switch to pnpm.
  • pnpm create next-app@latest . — scaffold in the current directory.

Pick the defaults (TypeScript + ESLint + Tailwind + App Router) unless you have a specific reason not to.

5. Local Development Loop

Files created by Docker as root cause Permission denied on the host. Always pass the host UID/GID:

docker run --rm -it \
  -u "$(id -u)":"$(id -g)" \
  -p 3000:3000 \
  -v "$PWD":/app \
  -w /app \
  node:22-bookworm \
  bash -lc "corepack enable && corepack prepare pnpm@latest --activate && pnpm dev --hostname 0.0.0.0"

--hostname 0.0.0.0 makes the bind address explicit. Next.js 16's next dev already defaults to 0.0.0.0, but passing the flag guards against older versions and documents the intent.

Recovery if files already became root-owned:

sudo chown -R $USER:$USER .

Wrap the long invocation in a dev.sh script — running it daily is the path of least friction:

#!/usr/bin/env bash
docker run --rm -it \
  -u "$(id -u)":"$(id -g)" \
  -p 3000:3000 \
  -v "$PWD":/app \
  -w /app \
  node:22-bookworm \
  bash -lc "corepack enable && corepack prepare pnpm@latest --activate && pnpm install && pnpm dev --hostname 0.0.0.0"

6. Pushing to GitHub

6.1 .gitignore Essentials

Confirm these patterns exist before the first commit:

node_modules/
.next/
out/
.env*
.pnpm-store/

Missing .pnpm-store/ is the common foot-gun — pnpm caches there inside the project, easily reaches 100 MB+, and GitHub rejects pushes containing files > 100 MB.

6.2 First Push

git init
git add .
git commit -m "init: nextjs site"
git branch -M main
git remote add origin [email protected]:USER/REPO.git
git push -u origin main

Create the GitHub repo empty (no auto-generated README / .gitignore / license) — those collide with the local scaffold.

7. Deploying on Vercel

  1. Sign up at vercel.com, Continue with GitHub.
  2. Install the Vercel GitHub App when prompted; grant access to either all repos or just this one.
  3. New Project → pick the repo → defaults (framework auto-detected) → Deploy.

You get https://<project-name>.vercel.app with HTTPS, CDN, and auto-deploy on every push to main. PRs and other branches get preview deployments at unique URLs.

8. Custom Domain via Cloudflare DNS

Verify the *.vercel.app URL works first — that isolates "deployment broken" from "DNS broken" if something fails later.

8.1 Add the Domain in Vercel

Project → Settings → Domains:

  • Add example.io
  • Add www.example.io
  • Set redirect example.io → www.example.io (or the reverse — pick a canonical, stay consistent).

Vercel shows the exact DNS records to add.

8.2 Add the Records in Cloudflare

Cloudflare → DNS → Records, typical setup:

Type Name Value Proxy TTL
A @ 76.76.21.21 DNS only (grey cloud) Auto
CNAME www cname.vercel-dns-0.com (or the project-specific value Vercel shows) DNS only Auto

Vercel now assigns each project a unique CNAME target (e.g. d1d4fc829fe7bc7c.vercel-dns-017.com); use whatever Vercel's Domains page shows for your project. cname.vercel-dns-0.com is the general-purpose value; the legacy cname.vercel-dns.com still resolves but is no longer the documented default.

Both records must be DNS-only (grey cloud). Cloudflare's orange-cloud proxy intercepts the request and prevents Vercel from verifying ownership and issuing the TLS certificate.

Back in Vercel, Refresh the domains page — both entries should flip to Valid within minutes.

9. DNS Records Explained

9.1 A Record

Maps a hostname to an IPv4 address.

example.io → 76.76.21.21

Use for the apex (bare) domain. Many DNS hosts don't allow CNAME on the apex by spec; Cloudflare supports CNAME flattening, but A is universally compatible.

9.2 CNAME Record

Aliases one hostname to another. The browser does an extra lookup hop.

www.example.io → cname.vercel-dns-0.com → (Vercel's current IPs)

Why useful: Vercel can change underlying IPs without you ever updating DNS — the CNAME always points at their current load balancer.

9.3 Other Record Types

Type Purpose
AAAA IPv6 address.
MX Mail server for the domain. See § 10.
TXT Arbitrary text. Used for SPF, DKIM, DMARC, domain verification.
NS Authoritative nameservers (set at the registrar to delegate to Cloudflare).
CAA Restricts which CAs can issue certs for this domain.

9.4 Why a www Canonical Domain

Three reasons people standardise on www.example.io:

  • One canonical host, no SEO dilution between example.io and www.example.io.
  • Apex-domain quirks (no CNAME) are simpler to skirt with www as canonical.
  • Future subdomain expansion (notes.example.io, api.example.io) feels consistent.

Both choices are valid; pick one, stay consistent.

10. Email — Receive and Send Independently

Email's two halves use completely different infrastructure. Forgetting this is the root cause of most setup confusion.

                       RECEIVE                             SEND
sender ──▶ DNS lookup ──▶ MX record ──▶ mail server     your_client ──▶ SMTP server ──▶ recipient's MX
                              │                              │
                              └─ "who collects mail for       └─ "who emits mail on behalf of
                                  example.io"                     example.io"
Direction Protocol DNS record that matters
Inbound (receive) SMTP delivery to your MX host MX
Outbound (send) SMTP submission via your sending server None directly — but SPF, DKIM, DMARC (all TXT) authorise/validate

Cloudflare Email Routing handles receiving. Gmail SMTP handles sending. SPF/DKIM/DMARC authorise Gmail to send as @example.io.

End architecture:

sender ──▶ MX (cloudflare) ──▶ forwards to ──▶ [email protected]
                                                       │
                                                       └─ "Send as" via Gmail SMTP, From: [email protected]

10.1 Cloudflare Email Routing — Inbound

In the Cloudflare dashboard for the domain:

  1. Email → Email Routing → Get started. Cloudflare offers to add MX records automatically; accept.

  2. Three records appear:

    MX  @  <name1>.mx.cloudflare.net   priority 13
    MX  @  <name2>.mx.cloudflare.net   priority 24
    MX  @  <name3>.mx.cloudflare.net   priority 86
    

    Cloudflare assigns three randomized first-name prefixes per zone (e.g. amir, linda, isaac) under *.mx.cloudflare.net; priorities are non-sequential. Use the exact values shown in your Cloudflare dashboard.

  3. Add a routing rule: [email protected][email protected].

  4. Click the verification link Cloudflare emails to your Gmail.

Test by sending to [email protected] from any account. It should land in Gmail.

10.2 Gmail "Send As" — Outbound

In Gmail: Settings → Accounts → Send mail as → Add another email address.

Field Value
Name Your display name
Email address [email protected]
Treat as alias Checked
SMTP Server smtp.gmail.com
Port 587 (TLS) or 465 (SSL)
Username [email protected]
Password A Gmail App Password (NOT your account password)
Security TLS

To generate an App Password:

  1. Google Account → Security → 2-Step Verification (must be on).
  2. App Passwords → generate one for "Mail".
  3. Paste it as the SMTP password.

Gmail emails a verification code to [email protected]; that arrives via the Cloudflare MX route above. Enter the code → done.

Now Gmail's compose can pick From: [email protected]. Replies go from the same address.

11. SPF

Sender Policy Framework — a TXT record on example.io listing which SMTP servers are allowed to send mail using From: @example.io. Receiving servers query it and reject mismatched senders.

Single TXT record at the apex:

TXT  @  "v=spf1 include:_spf.google.com include:_spf.mx.cloudflare.net ~all"
Token Meaning
v=spf1 SPF version. Must be first.
include:_spf.google.com Inherit Google's allowlist (covers Gmail SMTP).
include:_spf.mx.cloudflare.net Inherit Cloudflare's (covers Email Routing reply paths).
~all Soft-fail anything else — receiver flags as suspicious but may still deliver.
-all Hard-fail — reject. Use only after monitoring shows no false positives.
?all Neutral — neither pass nor fail. Useless except during transition.

Strict rules:

  • Only one SPF record per domain. Two TXT records starting with v=spf1 is invalid — many receivers will fail SPF entirely.
  • 10 DNS lookup limit total (each include counts). Long chains hit this fast.
  • SPF only checks the envelope MAIL FROM (also called Return-Path), not the visible From: header. DMARC ties them together.

12. DKIM

DomainKeys Identified Mail — the sending server signs each outgoing email with a private key; the public key lives in DNS as a TXT record at a _domainkey selector. Receivers verify the signature.

Caveat with the free Gmail flow: when sending as @example.io through Gmail SMTP, Gmail signs with its own keys (d=gmail.com), not yours. That's good enough for SPF + DKIM to pass individually, but DMARC's stricter "alignment" check (next section) requires the DKIM domain match the visible From: domain — which it won't unless you set up DKIM on example.io yourself.

To DKIM-sign with example.io, you need a sending platform that supports it: paid Google Workspace, Resend, Postmark, Mailgun, AWS SES, etc. They give you a CNAME or TXT to add at selector._domainkey.example.io. Example for Resend:

TXT  resend._domainkey  "v=DKIM1; p=MIIBIjANBgkqh..."

For a fully free personal setup, accept that DKIM alignment will fail and set DMARC to monitor mode (p=none, see next section) — most receivers still deliver because SPF passes and the message looks normal.

13. DMARC

Domain-based Message Authentication, Reporting & Conformance — sits on top of SPF and DKIM, enforces alignment between the authenticated domain and the visible From: domain, and tells receivers what to do when checks fail. Also requests aggregate reports back to a mailbox you control.

TXT  _dmarc  "v=DMARC1; p=none; rua=mailto:[email protected]; pct=100; adkim=r; aspf=r"
Tag Purpose
v=DMARC1 Version. Required, must be first.
p= Policy when alignment fails. none (just monitor), quarantine (deliver to spam), reject (drop).
sp= Same, for subdomains. Defaults to inheriting p.
rua=mailto:... Where to send aggregate XML reports (daily summaries).
ruf=mailto:... Where to send forensic per-message reports. Rarely supported now (privacy).
pct= Percent of failing mail to apply the policy to. Useful for gradual rollout (pct=10).
adkim= DKIM alignment: r relaxed (subdomain OK), s strict (exact match).
aspf= SPF alignment: r / s.

Rollout strategy:

  1. Start at p=none — collect reports for 1–4 weeks.
  2. Inspect the reports (Postmark's free DMARC analyzer is a good start) — identify all legitimate senders.
  3. Add any missing servers to SPF or set up DKIM for them.
  4. Move to p=quarantine; pct=10, then pct=100, then p=reject.

Without DKIM aligned with @example.io, do not move past p=none. Receivers will see all your Gmail-via-example.io mail as misaligned and silently drop it.

14. Authentication Flow End-to-End

When [email protected] receives a message claiming From: [email protected]:

1. Receiver looks up SPF for example.io
   → does the connecting SMTP IP match the SPF list? PASS/FAIL
2. Receiver checks DKIM
   → does the signature header verify against the published public key? PASS/FAIL
   → which domain (d=) did the signature use?
3. Receiver checks DMARC for example.io
   → was SPF alignment satisfied (envelope domain == From domain, modulo aspf rule)?
   → was DKIM alignment satisfied (d= domain == From domain, modulo adkim rule)?
   → at least one must pass for DMARC to pass.
4. If DMARC fails, apply the p= policy (none/quarantine/reject).
5. Receiver may send an aggregate report to the rua= mailbox.

15. Troubleshooting

15.1 GLIBC_2.xx not found

  • Symptom: host Node fails to launch.
  • Cause: distro glibc older than required.
  • Fix: don't upgrade glibc by hand. Use the Docker workflow — the host glibc never matters.

15.2 GitHub Push Rejected for Large Files

  • Symptom: GH001 Large files detected referencing .pnpm-store/<something>.
  • Cause: pnpm content-addressable store committed to history.
  • Fix: ignoring the path going forward is not enough — the blob is already in history.
echo ".pnpm-store/" >> .gitignore
git rm -r --cached .pnpm-store
git commit -m "chore: stop tracking pnpm store"
git push

If the giant blob is deep in history (not just unpushed), rewrite with git filter-repo or bfg.

15.3 Host Files Owned by root

  • Symptom: editor can't save files.
  • Cause: ran Docker without -u "$(id -u)":"$(id -g)".
  • Fix: sudo chown -R $USER:$USER ., then add the -u flag going forward.

15.4 Vercel GitHub OAuth Window Does Nothing

  • Symptom: click "Continue with GitHub" → no redirect.
  • Cause: privacy extension or strict cookie settings blocking the OAuth callback.
  • Fix: try incognito, disable ad-blocker, or sign in from another browser to complete the link once.

15.5 Vercel Domain Invalid Configuration

  • Cause #1: Cloudflare records still proxied (orange cloud).
  • Cause #2: A/CNAME values typo'd.
  • Verify exactly:
A     @     76.76.21.21              DNS only
CNAME www   cname.vercel-dns-0.com   DNS only   (or the project-specific value)

DNS propagation is typically < 5 minutes with Cloudflare. After updating, hit Refresh in Vercel's domain page.

15.6 Certificate Pending Forever

If Valid for DNS but TLS cert never issues, check for a CAA record on the domain — if you've previously locked it to a specific CA, Let's Encrypt (Vercel's issuer) gets blocked. Remove the restriction or add letsencrypt.org to the CAA list.

15.7 Common Email Failures

Symptom Likely cause Fix
SPF FAIL in DMARC report Wrong / missing include: for the sending platform Add the platform's include: to the single SPF TXT.
Multiple SPF records Domain has two v=spf1 TXTs Merge into one record with multiple include:s.
DMARC FAIL but mail still delivers p=none (monitor mode) — expected Either accept while configuring DKIM, or ignore the warning.
Gmail rejects App Password 2FA not on, or password copied with spaces Enable 2-Step Verification, regenerate, paste without whitespace.
Verification email never arrives Routing rule missing or wrong destination Cloudflare → Email Routing → check rule + ensure destination address is verified.

Test tools:

  • https://www.mail-tester.com/ — send a test message, get an authentication scorecard.
  • dig example.io TXT — verify SPF / DMARC TXT records published.
  • dig +short MX example.io — confirm MX hosts.

16. When to Upgrade

16.1 Beyond Vercel

Vercel handles personal sites, marketing pages, blogs, dashboards, and most CRUD apps perfectly. It also fits the typical complex UI — SSR, server actions, API routes — without changing platforms.

Use a more flexible host (Fly.io, Cloud Run, AWS, self-hosted) when you need:

  • WebSocket long connections — chat, realtime collaboration, multiplayer.
  • Long-running background tasks (video transcode, batch jobs, ML training/inference).
  • VPC peering, static outbound IP, or other network controls Vercel doesn't expose.
  • Self-hosting the database next to compute.

Hybrid is common: Vercel for frontend / SSR, a managed DB (Neon, Supabase, PlanetScale, RDS), and a separate worker tier for async or realtime work.

16.2 Beyond Free Email

The free Cloudflare + Gmail flow has hard limits:

  • One identity per domain (Gmail Send-As is per-user).
  • DKIM never aligns with example.io → can't ever go past p=none.
  • Cloudflare Email Routing forwards only; you can't host actual mailboxes.
  • Gmail rate-limits outbound from personal accounts — sufficient for personal mail, not for bulk.

Upgrade triggers:

Need Service
Multiple mailboxes / shared inboxes Google Workspace ($6+/user/month), Fastmail, Proton Mail
Transactional email (sign-ups, receipts) Resend, Postmark, Mailgun, AWS SES
Newsletters / marketing Buttondown, ConvertKit, Mailchimp
Full DMARC enforcement Any paid provider above — they configure DKIM for your domain

17. References