· Infra · 9 min read
How awkto.dev ships itself
The goal
I wanted a personal site with a blog that:
- Costs approximately nothing to run.
- Redeploys on
git push— no dragging files, no "which SFTP session was it again". - Gives me a preview URL per branch so I can try wildly different designs without risking the public one.
- Reads email sent to a proper
@awkto.devaddress, and lets me reply as that address from Gmail. - Fits the rest of the home lab philosophically — pragmatic, reproducible, mine.
No CMS. No Squarespace. No Vercel bill. Just the minimum machinery that makes the loop feel automatic.
This is the story of how that came together in one long evening.

Starting point: a Lovable-generated Vite app
The first version of the site came out of a Lovable prompt — a Vite + React + shadcn/ui SPA, client-rendered, with placeholder content. Good bones, fake projects. It was a free head start on layout and Tailwind tokens; everything else needed rewiring.
The plan was never to keep it SPA forever, but for a personal portfolio it's fine. The entire site builds to a dist/ folder of static assets. That made the hosting question trivial.
Step 1 — Cloudflare Workers as the static host
Three realistic options were on the table: Cloudflare Pages, Cloudflare Workers (with the newer Static Assets binding), or a plain VPS running nginx. I went with Workers Static Assets rather than Pages for one reason: it leaves the door open to add a tiny bit of Worker code later (contact form, OG image generation, an edge-side redirect) without having to migrate platforms.
wrangler.toml is five lines:
name = "awkto-portfolio"
compatibility_date = "2026-04-17"
workers_dev = true
[assets]
directory = "./dist"
not_found_handling = "single-page-application"
not_found_handling = "single-page-application" is the important detail: any request that doesn't match a static file falls through to index.html, so client-side routes like /blog/:slug work on hard refresh. Without that flag, /blog would 404.
A first npx wrangler deploy put the site on awkto-portfolio.<account>.workers.dev — free, globally edge-cached, valid TLS. That's already a usable dev environment.

Step 2 — GitHub Actions, so git push is the deploy command
Running wrangler deploy by hand works, but it's exactly the kind of chore that silently erodes the "I'll just push a blog post" feeling. CI on GitHub is the fix.
The whole workflow is under 30 lines:
name: Deploy to Cloudflare Workers
on:
push:
branches: [main, dev]
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
env:
WRANGLER_ENV_FLAG: ${{ github.ref_name == 'main' && ' ' || format('--env {0}', github.ref_name) }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22, cache: npm }
- run: npm ci
- run: npm run build
- uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: deploy ${{ env.WRANGLER_ENV_FLAG }}
The two secrets come from my secrets manager (OpenBao in the lab) and get pushed into GitHub Actions secrets via gh secret set — no token ever hits the repo or my shell history.
End result: every commit to main deploys production in about 50 seconds.

Step 3 — A dev branch that deploys to its own URL
The clever bit in that workflow is the WRANGLER_ENV_FLAG line. When the pushed branch is main, it evaluates to nothing (wrangler deploy). When it's anything else — initially just dev — it evaluates to --env dev.
That's paired with a second block in wrangler.toml:
[env.dev]
name = "awkto-portfolio-dev"
[[env.dev.routes]]
pattern = "dev.awkto.dev"
custom_domain = true
So dev branch pushes land in a completely separate Worker called awkto-portfolio-dev, bound to dev.awkto.dev. Production (awkto-portfolio on awkto.dev) can't be touched by a dev push even accidentally. They're as isolated as two independent sites.
A tiny sibling workflow, teardown-dev.yml, is manual-trigger-only and runs wrangler delete --env dev --force — so when I'm done iterating on a design I can take the preview down with one click in the Actions tab. A subsequent push to dev recreates it.

Step 4 — Buying awkto.dev and binding it to the Worker
The site was already live on awkto-portfolio.workers.dev, but that URL isn't the point of a personal portfolio.
Cloudflare Registrar offers .dev at cost (a few bucks a year). Crucially, when a zone is hosted on Cloudflare DNS, adding it to a Worker is one line in wrangler.toml:
[[routes]]
pattern = "awkto.dev"
custom_domain = true
Cloudflare provisions a TLS cert automatically. The apex-CNAME problem that plagued 2015-era static hosting doesn't exist here — Workers routes use CF's proprietary flattening, so awkto.dev (not just www.awkto.dev) points at the Worker without any DNS acrobatics.
www.awkto.dev → awkto.dev is a single Redirect Rule in the dashboard (dynamic expression concat("https://awkto.dev", http.request.uri.path), 301, preserve query string). One HTTP hop, handled at the edge, no Worker code required.
Why not my old
dnsif.casubdomain? My lab's public zone is on DigitalOcean DNS. Cloudflare only auto-provisions certs for zones it hosts, soawkto.dnsif.cawould have needed either an nginx reverse proxy in the lab with its own cert, or a paid Cloudflare for SaaS plan. Buyingawkto.devfresh on Cloudflare sidestepped the whole thing.
Step 5 — Email on the new domain
A personal domain without working email feels half-finished. I wanted:
- Inbound: anything sent to
*@awkto.devlands in my existing Gmail inbox — preserving theTo:header so I can see whether someone wrote tome@,hi@, orcerts@. - Outbound: when I reply, the
Fromreadsme@awkto.dev, not my gmail address.
Inbound: Cloudflare Email Routing
Email Routing is free on any Cloudflare plan, included with every zone. You enable it, add a destination (your existing Gmail, verified by clicking a link Cloudflare emails you), and set one catch-all rule: any address on the domain forwards to that destination. Cloudflare also auto-creates the necessary MX, SPF and DKIM records.
The whole thing is three API calls or three dashboard clicks. Crucially, To: headers are preserved — so if someone emails certs@awkto.dev, my Gmail inbox shows it arriving at certs@awkto.dev, not at my gmail address. I can filter and search by that.
Catch on the "mailbox" side: Email Routing is strictly a forwarder. It doesn't store email, it has no IMAP, there's nothing to pull down in Thunderbird. Gmail is the actual mailbox; Cloudflare is the fancy /etc/aliases on the path between the sender and Gmail.
Outbound: Resend as the SMTP relay
For replies as @awkto.dev, I added Resend (free tier: 3k emails/month). Resend gave me DKIM records to paste into Cloudflare DNS, verified the domain, and handed back SMTP credentials.
Those creds then go into Gmail's Settings → Accounts → Send mail as wizard:
SMTP Server: smtp.resend.com
Port: 587
Username: resend
Password: <resend API key>
Gmail sends a verification email to me@awkto.dev → Cloudflare Email Routing forwards it to my inbox → I click the link. After that, Gmail's compose window has a From dropdown with me@awkto.dev as an option. When I pick it and hit send, the outbound goes through Resend — not Google's SMTP — and recipients see a DKIM-signed message from awkto.dev.

How this differs from Mail-in-a-Box (previous setup)
Before awkto.dev, I had Mail-in-a-Box (MiaB) running on box.dnsif.ca for my jixi.ca domain. It's a legit mail server: Postfix + Dovecot + Roundcube + Z-Push + fail2ban + automated Let's Encrypt, all wrapped in a turnkey installer. For jixi.ca it still serves:
- Real IMAP mailboxes with on-disk storage — I can reach them from Thunderbird, Mutt, whatever.
- Multiple users, each with their own inbox and catch-all alias options.
- Full control over DNS — MiaB expects to be the DNS server for the domain, so it can add SPF/DKIM/DMARC/DANE records itself.
- Outbound delivery included, no separate relay needed.
The tradeoffs are real too:
- It wants total DNS control, which makes it awkward to pair with Cloudflare-hosted zones.
- You're now running — and patching, and monitoring — a full email server on the public internet.
- Spam reputation is yours to manage: new IPs go through a slow trust-building ramp, one compromised relay can blackhole your domain for weeks, and the big providers (Gmail, Outlook) are increasingly strict.
For awkto.dev I didn't need inboxes, multiple users, or IMAP — I already have Gmail. I just needed receive and send-as. So the CF Email Routing + Resend combination is strictly more appropriate: zero servers, zero maintenance, free, and indistinguishable from "a real mailbox" from the outside. For anything more substantial — multi-user, local storage, privacy-critical — MiaB is still the right tool.
What the final shape looks like
┌────────────┐ git push main ┌───────────────────┐
│ my laptop │ ──────────────────────────────▶│ GitHub repo │
└────────────┘ └─────────┬─────────┘
│ triggers
▼
┌────────────────────┐
│ GitHub Actions │
│ npm ci && build │
│ wrangler deploy │
└─────────┬──────────┘
│ uploads
▼
awkto.dev ◀───────────── Cloudflare Workers (Static Assets)
dev.awkto.dev ◀───────── (awkto-portfolio-dev Worker)
*@awkto.dev ──▶ CF Email Routing ──▶ madatoman@gmail.com
me@awkto.dev ◀── Resend SMTP ◀──── Gmail "Send mail as"
Nothing in that diagram is running on my own hardware. The home lab is busy doing other things; this site is deliberately the least-special thing I host.
Results
- Writing this post means adding a file at
content/posts/<slug>.mdand pushing tomain. That's it. Fifty seconds later it's at awkto.dev/blog. - Prototyping a new design means
git checkout -b redesign-x && git push. A separate*.awkto.devpreview appears. When I'm done,gh workflow run teardown-dev.ymltakes it back down. - Email "just works" — I can hand out
me@awkto.devon business cards, reply from it, and never think about a mail server again. - Total running cost: about
$12/yearfor the.devdomain. Nothing else. Cloudflare Workers, Email Routing, and Resend free tiers cover the rest.
Is this overbuilt for a personal site? Probably. But every piece of it earned its place for a different reason — and the end result is the same loop I'd want at work, only smaller.