Dev Diary #2: Moving to the Edge & Oura-Powered Telemetry

In Dev Diary #1, I talked about nuking my bloated, legacy portfolio site and rebuilding it from scratch. The code was clean, the layout was tight, and the styling was dialed in. But there was still a major infrastructure bottleneck: it was still running on my old VPS.

It was time to decommission the legacy server and move my personal web sandbox into the modern era.

The Legacy: Sakura VPS, Nginx, and Manual Ops

For years, this portfolio ran on a low-resource Sakura VPS (CentOS, 1GB RAM) hosted in Japan. It was fronted by Cloudflare's free DNS tier, which was fine, but the deployment workflow was straight out of 2012:

  1. Write code locally.
  2. Run npm run build to generate static files.
  3. Use scp -r to upload the out/ folder over SSH to the VPS.
  4. Manually configure Nginx blocks /etc/nginx/sites-available/ and reload the systemd daemon.
  5. Deal with CentOS security updates and server package maintenance.

It was completely over-engineered for a static site, yet incredibly tedious to maintain. Every small change required a manual deploy loop. If I wanted to host a dynamic API in the future, a 1GB VPS would run out of memory before I could even finish running npm install.

The Migration: Hello, Cloudflare Pages

To simplify my life, I decided to split the architecture. All static assets—my portfolio page, technical projects list, and this blog—moved to Cloudflare Pages.

Cloudflare Pages is a dream for Next.js static exports (output: 'export'). You link your GitHub repository, choose the Next.js static preset, and you're done.

  • Zero Server Overhead: No OS to update, no ports to secure, no systemd files to debug.
  • Native Clean Routing: A common headache with static exports on CloudFront or S3 is that /portfolio returns a 404 because the server is looking for a folder instead of /portfolio.html. Cloudflare Pages handles this URL rewriting natively.
  • Branch Previews: Every commit pushed to a feature branch gets its own secure preview URL.

With this move, my static site hosting costs dropped to $0/month while performance scaled across Cloudflare’s 300+ global edge locations.


Adding Biophysical Telemetry (The Oura Ring API)

Once the foundation was moved, I wanted to turn my portfolio into a true sandbox. Instead of just static descriptions, I wanted to display my live biometric status—my daily recovery, sleep quality, and activity scores—directly under my name on the homepage.

To do this, I integrated the Oura Ring API v2.

I wrote a backend helper lib/oura.ts to query my latest sleep, readiness, and activity scores. Because exposing my personal access token to the browser would be a catastrophic security leak (allowing anyone to view my health data in the Network tab), I kept the fetch strictly on the server side:

// lib/oura-status.ts
export async function getOuraStatus() {
  // Safe server-side fetch utilizing OURA_PERSONAL_ACCESS_TOKEN
  const [sleep, readiness, activity] = await Promise.all([
    getSleepData(14),
    getReadinessData(14),
    getActivityData(14),
  ])
  
  // Compute daily status: UNSTOPPABLE, SOLID, MEH, STRUGGLING, or SOS
  const avg = Math.round((sleep.score + readiness.score + activity.score) / 3)
  return { status: getTier(avg), score: avg, ... }
}

This server component retrieves the biometrics securely during the Next.js build step, passing the values to my components without leaking credentials to the client.


The "Static Page Time Freeze" Headache

This led to a funny realization.

Next.js compiles static pages at build time. When Cloudflare Pages builds the repository on its servers, it executes getOuraStatus(), grabs the current score, and bakes it into a static index.html file.

After deploying, I loaded the website and saw:

Today's Vibe: 💪 SOLID | Oura: 78 · Evening update at 5:10 PM Tokyo time.

I checked back a few hours later. It still read 5:10 PM. I woke up the next morning. It still read 5:10 PM.

Because the site is 100% static, my daily status was frozen in time, capturing a snapshot of whatever minute Cloudflare last ran next build. Unless I committed code and pushed it to GitHub, my health score would stay exactly the same forever.


The Solution: A 1-Hour Cron Webhook

I didn't want to make my pages dynamic (which requires a warm serverless node and introduces latency). I wanted to keep the speed of pure static HTML, but keep the status fresh.

Since Oura Ring scores update throughout the day (as you log steps or wake up in the morning), rebuilding the site periodically is the perfect solution.

To automate this, I set up a Cloudflare Deploy Hook (a unique, secret URL that triggers a rebuild when pinged) and combined it with a GitHub Actions schedule cron job.

I added a new workflow file to the project:

# .github/workflows/rebuild.yml
name: Scheduled Rebuild

on:
  schedule:
    # Runs at minute 0 of every hour
    - cron: '0 * * * *'
  # Allows manual triggers from the Actions tab
  workflow_dispatch:

jobs:
  rebuild:
    runs-on: ubuntu-latest
    steps:
      - name: Trigger Cloudflare Pages Deploy Hook
        run: |
          curl -X POST "${{ secrets.CLOUDFLARE_DEPLOY_HOOK }}"

How it works:

  1. GitHub Scheduler: Every hour, GitHub Actions spins up a lightweight virtual machine.
  2. Ping Webhook: The runner executes a simple curl command, sending a POST request to my secret Cloudflare deploy hook.
  3. Cloudflare Rebuild: Cloudflare receives the hook, pulls the latest commit, runs npm run build (which pulls my latest live Oura biometrics), and updates the CDN edge cache.

If I'm coding late at night, the site compiles at 2:00 AM, and my home page status automatically updates to:

Today's Vibe: 😴 STRUGGLING | Oura: 52 ⏰ Late-night build at 2:00 AM Tokyo time. Duc is still awake pushing code. Sleep debt incoming.

What's Next?

Now that the static portfolio foundation is fast, automated, and secure, I'm thinking about what dynamic sandbox feature to build next. I have two ideas currently competing for priority:

  1. Deploying a Dedicated Dynamic Server: Setting up a proper backend server to host dynamic sub-applications, utilities, and lightweight APIs.
  2. Embedding an AI Chatbot: Building a custom conversational agent directly into the website that can talk about my work, answer questions about my engineering history, and roast my Oura Ring scores in real-time.

I'll document whichever path I take in the next entry. Stay tuned!