I wanted a simple place to document my side projects, explain why I built them, and keep notes on the technical trade-offs. I did not want to maintain a database or deal with a heavy CMS. This site is just a static SvelteKit 5 app that rebuilds on every git push and serves from m-r-t.xyz.

Almost the entire setup was done straight from the terminal. We created the GitHub repository, provisioned the Cloudflare DNS zone, pushed the DNS records, and enabled HTTPS certificates without clicking around web dashboards.

1. Static Prerendering with SvelteKit 5

The site runs on Svelte 5 and SvelteKit using @sveltejs/adapter-static. Because +layout.ts exports prerender = true and trailingSlash = "always", Vite compiles the whole app into static HTML, CSS, and small client chunks inside build/.

When the site first went up on the default github.io project URL (/mrt-blog), it needed a subpath prefix. Once the custom domain (m-r-t.xyz) went live, it needed to serve from the root path (/). Reading BASE_PATH from the build environment let us switch between the two without touching any route links:

svelte.config.js javascript
import adapter from '@sveltejs/adapter-static';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';

const config = {
	preprocess: vitePreprocess(),
	kit: {
		adapter: adapter({
			pages: 'build',
			assets: 'build',
			fallback: '404.html',
			strict: true
		}),
		paths: {
			base: process.argv.includes('dev') ? '' : (process.env.BASE_PATH ?? '')
		}
	}
};

export default config;

2. Content as Code

I wanted to keep project cards and write-ups right next to the code. Instead of hooking up an external CMS, all projects and posts live in src/lib/data/content.ts as typed TypeScript objects.

Adding a new project write-up takes three quick steps:

  • Project card: Add an entry to the projects array in src/lib/data/content.ts with its GitHub repo URL, status, and stack tags.
  • Post content: Add a post entry to the posts array with an overview card, sections, code snippets, and team credits.
  • Build-time discovery: SvelteKit reads every post slug via an entries() generator and prerenders /posts/[slug]/index.html during npm run build.

3. CI/CD with GitHub CLI & GitHub Actions

We initialized the repository and turned on GitHub Pages from the terminal using the GitHub CLI (gh repo create and gh api /repos/:owner/:repo/pages -f build_type=workflow).

On every push to main, .github/workflows/deploy.yml installs dependencies, checks actions/configure-pages@v5 for the active base_path, runs the build, and deploys the static artifact:

.github/workflows/deploy.yml yaml
steps:
  - uses: actions/checkout@v4
  - uses: actions/setup-node@v4
    with:
      node-version: 22
      cache: npm
  - run: npm ci
  - name: Configure GitHub Pages
    id: pages
    uses: actions/configure-pages@v5
  - name: Build static SvelteKit site
    env:
      BASE_PATH: ${{ steps.pages.outputs.base_path }}
    run: npm run build
  - uses: actions/upload-pages-artifact@v3
    with:
      path: 'build'
  - uses: actions/deploy-pages@v4

4. Custom Domain Setup: Namecheap to Cloudflare to GitHub Pages

I registered m-r-t.xyz on Namecheap, but I prefer keeping DNS management on Cloudflare so all of my domains are in one place and programmable from the CLI.

Each layer in the chain handles one job:

  • Registrar (Namecheap): Holds the domain registration and points custom nameservers to Cloudflare.
  • DNS Provider (Cloudflare): Hosts the DNS zone and serves A, AAAA, and CNAME records pointing to GitHub Pages.
  • Origin & TLS (GitHub Pages / Fastly CDN): Serves the static build based on static/CNAME and provisions a Let's Encrypt TLS certificate.
Deep Dive: Bypassing the 60-day ICANN transfer lock

Newly registered domains have a mandatory 60-day ICANN lock before you can transfer the registrar itself to Cloudflare Registrar.

You do not actually need to wait 60 days to use Cloudflare DNS. By leaving Namecheap as the registrar of record and updating the domain's custom nameservers to the pair assigned by Cloudflare, 100% of DNS management moves over to Cloudflare's API right away.

5. Automating Cloudflare DNS via the REST API

Instead of manually entering nine DNS records in a dashboard, we put a scoped Cloudflare API token in a local, gitignored .env file (with Zone:Edit and DNS:Edit permissions) and scripted the zone setup with curl.

We also set proxied: false (DNS-only mode) on every record. That lets GitHub Pages verify domain ownership directly against its Fastly edge IPs and issue its Let's Encrypt certificate without running into Cloudflare SSL proxy conflicts:

provision-dns.sh (excerpt) bash
# 1. Create the zone in Cloudflare
curl -s -X POST "https://api.cloudflare.com/client/v4/zones" \
  -H "Authorization: Bearer $CF_API_TOKEN" \
  -H "Content-Type: application/json" \
  --data '{"name":"m-r-t.xyz","type":"full"}'

# 2. Point apex A & AAAA records + www CNAME to GitHub Pages
for ip in 185.199.108.153 185.199.109.153 185.199.110.153 185.199.111.153; do
  curl -s -X POST "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records" \
    -H "Authorization: Bearer $CF_API_TOKEN" \
    -H "Content-Type: application/json" \
    --data "{\"type\":\"A\",\"name\":\"m-r-t.xyz\",\"content\":\"$ip\",\"proxied\":false}"
done

curl -s -X POST "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records" \
  -H "Authorization: Bearer $CF_API_TOKEN" \
  -H "Content-Type: application/json" \
  --data '{"type":"CNAME","name":"www","content":"mrtoronto.github.io","proxied":false}'

6. Verifying Propagation & Enforcing HTTPS

After updating the nameservers on Namecheap, we just had to wait about 30 minutes for the .xyz TLD registry (CentralNic) to publish the NS delegation.

Once the A records resolved publicly, we ran one GitHub API call to trigger certificate verification and enforce HTTPS redirects across the site:

enable-https.sh bash
# Set custom domain and enforce HTTPS via GitHub CLI
gh api --method PUT /repos/mrtoronto/mrt-blog/pages \
  -f cname="m-r-t.xyz" \
  -F https_enforced=true \
  -f build_type="workflow"
Deep Dive: Why dig @a.nic.xyz NS +short looked empty during propagation

While polling the .xyz TLD server directly with dig @a.nic.xyz m-r-t.xyz NS +short, the output stayed completely blank even after delegation was live.

That happens because TLD referral servers return delegated NS records in the AUTHORITY section of the DNS packet rather than the ANSWER section, and +short only prints the ANSWER section. Dropping +short (or querying a recursive resolver like dig @1.1.1.1 m-r-t.xyz NS +short) confirmed the Cloudflare nameservers were already active.