← all posts

· Infra · 9 min read

How awkto.dev ships itself

The goal

I wanted a personal site with a blog that:

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.

Final homepage

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.

wrangler deploy output

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.

GitHub Actions run summary

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.

dev.awkto.dev showing a different color palette

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.ca subdomain? My lab's public zone is on DigitalOcean DNS. Cloudflare only auto-provisions certs for zones it hosts, so awkto.dnsif.ca would have needed either an nginx reverse proxy in the lab with its own cert, or a paid Cloudflare for SaaS plan. Buying awkto.dev fresh on Cloudflare sidestepped the whole thing.

Step 5 — Email on the new domain

A personal domain without working email feels half-finished. I wanted:

  1. Inbound: anything sent to *@awkto.dev lands in my existing Gmail inbox — preserving the To: header so I can see whether someone wrote to me@, hi@, or certs@.
  2. Outbound: when I reply, the From reads me@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.

Gmail send-as configured with Resend

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:

The tradeoffs are real too:

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

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.