Building a photography portfolio on Cloudflare’s full stack


Two photography portfolios, one codebase. The goal: somewhere to host photos that is not Instagram, not Flickr, not a third-party album that disappears when the company pivots. I wanted to learn Cloudflare Images properly. The result is two independent portfolio sites built from the same React app, the same Pages Functions , and the same D1 schema - with no shared data between them.

The two sites live at alex.edestudio.us and jamie.edestudio.us . Same React app, same Pages Functions, same migrations, but two custom domains, two separate D1 databases, two independent photo catalogues, and two admin areas behind Cloudflare Access . One codebase, two products.

This post is the tour. Five deep dives follow it, one per piece of the stack.


Nothing is hosted on a machine I own. The whole site lives on Cloudflare:

  • Cloudflare Pages serves the React app. Vite builds it, Pages serves it, a generated _redirects file rewrites client routes to index.html so deep links like /photos/<slug> work on a direct load.
  • Pages Functions at /api/* handle everything dynamic: listing photos, minting upload URLs, saving D1 rows, proxying original-quality downloads.
  • D1 holds the metadata. Five small tables (photos, tags, photo_tags, albums, album_photos) plus a per-site settings table for things like the home page title and social handles.
  • Cloudflare Images stores and serves the actual pixels, with flexible delivery and named variants for the grid, the photo page, and the hero.
  • Cloudflare Access sits in front of /admin* and /api/admin* on each production hostname. Two emails per site, no shared password, no session cookies I have to manage.
  • Workers AI runs vision-model suggestions for descriptions, alt text, and tags when I upload, so I write less and the alt-text quality stays up.

The whole thing deploys from a Gitea repo on each push to main. Two jobs in one workflow point at the same code but different Pages projects (photography and photography-jamie) and different D1 databases.


Pick any photo on either site. Here is what happened to it.

1. EXIF strip in the browser. Before the file leaves the page, a small library at src/lib/exifStrip.ts removes the entire EXIF block (GPS, camera serial, software version, capture timestamp, and yes the orientation tag too), plus IPTC and comment segments. Cloudflare Images bakes rotation into the pixels on upload, so the photo still displays the right way up even with the orientation hint gone. The file that leaves the browser is the clean version.

2. Direct Creator Upload. The browser calls POST /api/admin/upload-url, which mints a short-lived Cloudflare Direct Creator Upload URL and returns { id, uploadURL }. The browser then POSTs multipart/form-data with field name file straight to uploadURL. Multi-megabyte images never travel through a Pages Function on the way to storage.

3. D1 rows. Once the upload resolves, the browser calls POST /api/admin/photos with the new cf_image_id, a slug, title, description, and any tag and album ids. One Pages Function call writes into photos, photo_tags, and album_photos so the photo is queryable immediately.

4. Public delivery. When you load the gallery, the React app renders an <img> whose srcSet is generated from IMAGE_DELIVERY=flexible plus the configured widths. The browser fetches the right size for its viewport directly from Cloudflare Images. There is no resize-on-request through my code: the CDN does it.

5. Optional download. Click the download icon on a photo page and the request hits /api/public/download/:slug, which proxies the original from the Images API blob endpoint (/images/v1/{id}/blob) and sets Content-Disposition: attachment. Originals never go through a delivery variant, only the blob endpoint. That detail is the one bit of the Cloudflare Images docs I had to read twice.


Three things tripped me up enough to remember.

Wrangler 4.x removed --env for Pages. I wanted one repo, two Pages projects. The old answer was wrangler pages deploy --env jamie. That no longer works: wrangler pages deploy reads only wrangler.jsonc at deploy time, with no --config and no --env. The fix is in scripts/deploy-pages.mjs, which writes a .wrangler/deploy/config.json file that redirects Wrangler to wrangler.jamie.jsonc before running the deploy. It is the supported indirection , not a hack, but I lost a couple of hours finding it. Full write-up in the next post.

Local dev fought me for an evening. Wrangler will not let you set pages_build_output_dir in config and also use wrangler pages dev -- <command> to launch Vite. You pick one. I run Vite and Wrangler side by side with concurrently, Wrangler in --proxy 5173 mode, plus a predev step that clears dist/ so Wrangler does not parse a stale _redirects from an earlier production build. Once that was straight, npm run dev just worked.

Cloudflare Access has to be on the custom hostname. The first time I deployed, I put the Access application on photography.pages.dev. Then I added the alex.edestudio.us custom domain , hit /admin, and got a login page that never completed. Access only protects what you tell it to. Adding the custom hostname to the Access application fixed it in one click, but the symptom was confusing enough that Cloudflare has a known-issues page dedicated to it.


Five follow-up posts, one per piece of the stack:

  1. Two sites, one codebase. The scripts/deploy-pages.mjs indirection, the per-site D1, and how the Gitea workflow runs both deploys from one push.
  2. Cloudflare Images flexible delivery and retina srcSet. Variant setup via npm run images:setup, generating srcSet from a single source, and the original-download blob endpoint.
  3. Direct Creator Upload from the browser. Why a Pages Function should not proxy your image bytes, and what the three-request handshake actually looks like in DevTools.
  4. Stripping EXIF before upload and backfilling existing photos. What I strip, what I keep, and the npm run backfill:exif script that cleans up everything uploaded before the strip existed.
  5. AI-assisted captions, alt text, and tags. The vision-model dispatcher, the prompt I settled on, and where Workers AI is useful versus where it confidently invents.

Each one stands alone, so jump straight to whichever piece sounds most interesting.


Both sites are public. Have a look.

If you are putting something similar together on Cloudflare and have a question, or you spot a bit of the stack I should be doing differently, reach out on LinkedIn .

×
Page views: