When I set out to rebuild my portfolio, I had one rule: keep the infrastructure as simple and cheap as possible without sacrificing performance. After trying Vercel, Netlify, and GitHub Pages, I landed on Cloudflare Pages — and then discovered the entire Cloudflare ecosystem that makes it even better.

Here’s the full guide to building a professional developer portfolio on Cloudflare’s free tier.

Why Cloudflare Pages

I’ve used all the popular hosting platforms. Here’s why I switched:

FeatureCloudflare PagesVercelNetlify
Free builds/month5006000 min300 min
BandwidthUnlimited100 GB100 GB
Custom domainsUnlimited50Varies
Edge locations300+~20~12
Build time (Astro)~4s~15s~20s
Preview deploymentsYesYesYes

The killer feature? Unlimited bandwidth on the free tier. If your blog post goes viral on Hacker News, you won’t get a surprise bill.

Setting Up Cloudflare Pages

1. Connect Your Repository

Go to Workers & PagesCreatePagesConnect to Git.

Select your GitHub (or GitLab) repo. Cloudflare will ask for build settings:

Framework preset: Astro
Build command:    npm run build
Build output:     dist
Root directory:   /  (leave blank if project is at repo root)

2. Environment Variables

If your site needs environment variables at build time, add them in SettingsEnvironment variables:

NODE_VERSION=20
# Add any API keys or config needed during build

3. Deploy

Push to main and Cloudflare builds and deploys automatically. Every push to other branches creates a preview deployment with a unique URL — great for reviewing changes before merging.

Production:  https://your-project.pages.dev
Preview:     https://abc123.your-project.pages.dev

4. Custom Domain

In your Pages project → Custom domainsSet up a custom domain:

  1. Enter your domain (e.g., luonghongthuan.com)
  2. Cloudflare automatically creates the DNS record
  3. SSL certificate is provisioned automatically
  4. Done. No Certbot, no renewal headaches.

Adding Dynamic Features with Workers

A static site is great, but sometimes you need server-side logic. Instead of spinning up a whole backend, use Cloudflare Pages Functions — they’re Workers that live alongside your static site.

Contact Form API

I built my contact form handler as a Pages Function. Create a file at functions/api/contact.ts:

interface Env {
  CONTACT_SUBMISSIONS: KVNamespace;
  TURNSTILE_SECRET: string;
}

export const onRequestPost: PagesFunction<Env> = async (context) => {
  const body = await context.request.json();
  const { name, email, subject, message } = body;

  // Validate
  if (!name || !email || !message) {
    return new Response(
      JSON.stringify({ error: 'Missing required fields' }),
      { status: 400 }
    );
  }

  // Store in KV
  const id = `contact_${Date.now()}`;
  await context.env.CONTACT_SUBMISSIONS.put(id, JSON.stringify({
    name, email, subject, message,
    timestamp: new Date().toISOString(),
  }));

  return new Response(
    JSON.stringify({ success: true }),
    { status: 200 }
  );
};

This function runs on Cloudflare’s edge — 300+ data centers worldwide. Your contact form responds in milliseconds no matter where the visitor is.

Setting Up KV Storage

KV (Key-Value) is Cloudflare’s global, low-latency data store. Perfect for simple use cases like form submissions.

# Create a KV namespace
npx wrangler kv namespace create CONTACT_SUBMISSIONS

# Add the binding to wrangler.jsonc

Then bind it in your wrangler.jsonc:

{
  "kv_namespaces": [
    {
      "binding": "CONTACT_SUBMISSIONS",
      "id": "your-namespace-id-here"
    }
  ]
}

Using R2 for Image Storage

If your portfolio has lots of images (project screenshots, blog hero images), consider Cloudflare R2 instead of committing them to git.

Why R2?

  • 10 GB free storage on the free tier
  • No egress fees (this is huge — AWS S3 charges for every byte served)
  • Custom domain support — serve images from images.yourdomain.com
  • S3-compatible API — works with existing tools

Setup

# Create an R2 bucket
npx wrangler r2 bucket create portfolio-images

# Upload images
npx wrangler r2 object put portfolio-images/hero/blog-post-1.jpg --file ./hero.jpg

Then reference in your markdown:

heroImage: "https://images.luonghongthuan.com/hero/blog-post-1.jpg"

D1 for More Complex Data

If you outgrow KV (maybe you want to query submissions by date, or add a view counter), Cloudflare D1 is a serverless SQLite database.

# Create a D1 database
npx wrangler d1 create portfolio-db

# Create a migration
npx wrangler d1 migrations create portfolio-db init

Example migration for a page view counter:

-- migrations/0001_init.sql
CREATE TABLE page_views (
  path TEXT PRIMARY KEY,
  count INTEGER DEFAULT 0,
  last_viewed TEXT
);

CREATE TABLE contact_submissions (
  id TEXT PRIMARY KEY,
  name TEXT NOT NULL,
  email TEXT NOT NULL,
  subject TEXT,
  message TEXT NOT NULL,
  created_at TEXT DEFAULT (datetime('now'))
);

Apply it:

npx wrangler d1 migrations apply portfolio-db

Performance Optimization

1. Cache Rules

Cloudflare caches static assets by default, but you can fine-tune it. Go to CachingCache Rules:

# Cache blog images aggressively
Match: URI Path contains /images/
TTL: 30 days

# Don't cache API responses
Match: URI Path starts with /api/
Cache: Bypass

2. Speed Optimizations

In SpeedOptimization:

  • Auto Minify: Enable for JavaScript, CSS, HTML
  • Early Hints: Enable (preloads assets before the page loads)
  • HTTP/3: Enable for faster connections

3. Compression

Cloudflare automatically applies Brotli compression. For an Astro site, this typically reduces page size by 70-80%.

My homepage:

  • Uncompressed: 42 KB
  • Brotli compressed: 11 KB
  • Time to First Byte: ~20ms (from Cloudflare edge)

Web Analytics (Free, No JS)

Cloudflare offers free web analytics that don’t require a JavaScript snippet — they work at the DNS/proxy level, so they capture all traffic including visitors with ad blockers.

Enable it in Analytics & LogsWeb Analytics. You get:

  • Page views and unique visitors
  • Top pages and referrers
  • Country breakdown
  • Core Web Vitals (LCP, FID, CLS)
  • All without adding a single line of code

My Final Architecture

┌─────────────────────────────────────────┐
│           Cloudflare Edge (300+ PoPs)   │
├─────────────────────────────────────────┤
│  DNS          │ Cloudflare Registrar    │
│  CDN + Cache  │ Automatic               │
│  SSL          │ Free, auto-renewed      │
│  DDoS         │ Always-on               │
│  Analytics    │ DNS-level, no JS needed  │
├─────────────────────────────────────────┤
│  Pages        │ Static site (Astro)     │
│  Functions    │ Contact form API        │
│  KV           │ Form submissions        │
│  Turnstile    │ Bot protection          │
└─────────────────────────────────────────┘

Monthly cost: $0. Annual cost: ~$10 for the domain.

Common Pitfalls

1. Build Output Directory

Astro outputs to dist/ by default. Make sure your Pages build config matches:

Build output directory: dist

Not _site, not public, not build. This trips people up more often than you’d think.

2. SPA vs. MPA Routing

Astro generates static HTML files. If you get 404s on direct page loads, make sure you’re not using hash routing. Cloudflare Pages serves static files — /blog/my-post/index.html works out of the box.

3. Function Routes

Pages Functions must match the URL path:

  • functions/api/contact.ts/api/contact
  • functions/submit.ts/submit

Don’t put them inside src/ — they go in the root functions/ directory.

4. Preview vs. Production Environment Variables

Environment variables can be set separately for preview and production in Pages settings. Don’t forget to set them for both if needed.

What’s Next

This setup has served me well for months with zero maintenance. The site scores 100 on Lighthouse across all categories, loads in under 500ms worldwide, and costs practically nothing.

If you’re a developer building a portfolio, Cloudflare’s free tier is genuinely hard to beat. The combination of Pages, Workers, KV, and the global CDN gives you a production-grade infrastructure that used to require multiple services and a monthly bill.

Export for reading

Comments