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.
What’s actually running ¶
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
_redirectsfile rewrites client routes toindex.htmlso 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-sitesettingstable 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.
A walk through one photo’s life ¶
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.
What was hard ¶
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.
What the deep dives cover ¶
Five follow-up posts, one per piece of the stack:
- Two sites, one codebase.
The
scripts/deploy-pages.mjsindirection, the per-site D1, and how the Gitea workflow runs both deploys from one push. - Cloudflare Images flexible delivery and retina
srcSet. Variant setup vianpm run images:setup, generatingsrcSetfrom a single source, and the original-download blob endpoint. - 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.
- Stripping EXIF before upload and backfilling existing photos.
What I strip, what I keep, and the
npm run backfill:exifscript that cleans up everything uploaded before the strip existed. - 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.
Try it ¶
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 .