CDN Architecture
CDN Architecture
Technical reference for the CDN infrastructure. For daily image workflow, see Image Workflow.
Architecture
graph TB
subgraph "Cloudflare"
R2[R2 Bucket: zmodmedia<br/>pub-8bb5199db1d947eb8e50e01c018498c8.r2.dev]
end
subgraph "Netlify"
N1[Main Site<br/>takazudomodular.com]
end
U[User Request] -->|/images/p/*| N1
N1 -->|Proxy Redirect 200| R2
All product images are stored in a single R2 bucket and served through Netlify proxy redirects. The browser sees the main domain — R2 is invisible to users.
Components
Main Site (takazudomodular.com)
- Build: Pre-built Astro site deployed from CI/CD
- Content: Application code, HTML, CSS, JavaScript
- Images: None (proxied to R2)
- Configuration:
netlify.tomlandstatic/_redirectswith proxy rules
Cloudflare R2 Bucket (zmodmedia)
- Content: All processed product images
- Formats: WebP (5 sizes), JPG (OGP, only for
__og/__ogonlyfiles), PNG (mercari), JSON (metadata) - Cache:
Cache-Controlheaders set at upload time - Public URL:
https://pub-8bb5199db1d947eb8e50e01c018498c8.r2.dev
Configuration Files
netlify.toml
Located at: /netlify.toml
# Production CDN Redirects - Cloudflare R2
[[redirects]]
from = "/images/p/*"
to = "https://pub-8bb5199db1d947eb8e50e01c018498c8.r2.dev/images/p/:splat"
status = 200
force = true
[[redirects]]
from = "/static/images/p/*"
to = "https://pub-8bb5199db1d947eb8e50e01c018498c8.r2.dev/images/p/:splat"
status = 200
force = true
[[redirects]]
from = "/i/*"
to = "https://pub-8bb5199db1d947eb8e50e01c018498c8.r2.dev/images/p/:splat"
status = 200
force = true
- Redirects use
status = 200(proxy) so the browser sees the main domain force = trueensures redirects override any local files/i/*is a legacy short-path alias
static/_redirects
Located at: /static/_redirects
# Proxy all product images to Cloudflare R2
/images/p/* https://pub-8bb5199db1d947eb8e50e01c018498c8.r2.dev/images/p/:splat 200!
/static/images/p/* https://pub-8bb5199db1d947eb8e50e01c018498c8.r2.dev/images/p/:splat 200!
static/_headers (Cache Configuration)
Located at: /static/_headers
# 1 week caching for processed images
/images/p/*
Cache-Control: public, max-age=604800
# 1 week caching for font files
/fonts/*
Cache-Control: public, max-age=604800
# Standard caching for other static assets
/static/*
Cache-Control: public, max-age=86400
# No caching for HTML pages
/*.html
Cache-Control: no-cache, no-store, must-revalidate
URL Patterns
| Request Path | Proxied To | Content Type |
|---|---|---|
/images/p/oxi-one-mk2/600w.webp | R2: images/p/oxi-one-mk2/600w.webp | Product image |
/images/p/oxi-one-mk2/mercari.png | R2: images/p/oxi-one-mk2/mercari.png | Marketplace image |
/images/p/oxi-one-mk2/ogp.jpg | R2: images/p/oxi-one-mk2/ogp.jpg | OGP image (only for __og / __ogonly files) |
/images/p/oxi-one-mk2/metadata.json | R2: images/p/oxi-one-mk2/metadata.json | Image metadata |
Caching Strategy
R2 Upload Headers
Set at upload time by scripts/upload-images-to-r2.mjs:
- Images (webp, jpg, png, gif):
public, max-age=31536000, immutable(1 year) - Metadata JSON:
public, max-age=3600(1 hour)
Netlify Cache Headers
Configured in static/_headers:
- Images:
public, max-age=604800(1 week, Netlify edge) - HTML Pages:
no-cache, no-store, must-revalidate
Deployment
Main Site
-
Build Phase (GitHub Actions):
pnpm build # Builds site to dist/ -
Deploy Phase (CI/CD):
pnpm deploy:prod # Uses scripts/deploy-production.sh netlify deploy --prod --no-build --dir=dist
The --no-build flag prevents Netlify from attempting to build, as we deploy pre-built files.
Image Deployment to R2
Image uploads are independent of main site deployments. The upload script uses incremental sync (MD5/ETag comparison), so re-running is safe and fast when nothing has changed.
Configuration Map
| Configuration | Location | Purpose |
|---|---|---|
| Netlify redirects | /netlify.toml, /static/_redirects | R2 proxy rules |
| Cache headers | /static/_headers | Browser caching policies |
| R2 credentials | .env (local), GitHub Secrets (CI) | S3-compatible access |
| Upload script | /scripts/upload-images-to-r2.mjs | Push images to R2 |
| Download script | /scripts/download-images-from-r2.mjs | Pull images from R2 |
| Orphan check | /scripts/check-r2-orphans.mjs | Detect orphaned R2 images |
| Sync verifier | /scripts/verify-r2-sync.mjs | Pre-push sync check |
| CDN URL config | /lib/images/metadata-loader.ts | Runtime URL generation |
CI E2E Testing
In the production deploy pipeline, E2E tests run against the static build served by the serve package, which does not support Netlify redirects. The CI image interceptor (tests/e2e/helpers/ci-image-interceptor.js) handles this:
How It Works
- Playwright route interception: When
CI=true,setupCiImageInterception()intercepts all**/images/p/**requests at the browser level - Placeholder responses: Returns tiny 1x1 pixel placeholders (WebP, JPEG, PNG, GIF) based on URL extension. Extensionless URLs (e.g.,
/images/p/addac401-level) default to WebP - Pass-through for non-images: Only
metadata.jsonand other non-image files pass through to the server
Defense in Depth
The E2E spec files also skip /images/p/ URLs in their 404 checkers. This provides a safety net if any image request slips past the interceptor:
// In full-check-production.spec.js and full-check-production-ci.spec.js
!requestUrl.includes('/images/p/')
Image Availability Verification
Actual image availability on R2 is verified separately by pnpm r2:verify-sync (scripts/verify-r2-sync.mjs), not by E2E tests. E2E tests focus on page rendering and JavaScript errors.
Troubleshooting
Images Not Loading (404)
# Check if redirect is working
curl -IL https://takazudomodular.com/images/p/oxi-one-mk2/600w.webp
# Check R2 directly
curl -I https://pub-8bb5199db1d947eb8e50e01c018498c8.r2.dev/images/p/oxi-one-mk2/600w.webp
Verify: image exists in R2, redirect rules in netlify.toml are correct, R2 bucket public access is enabled.
Cache Not Working
# Check cache headers — should include:
# Cache-Control: public, max-age=31536000, immutable
curl -I https://takazudomodular.com/images/p/oxi-one-mk2/600w.webp
If missing, re-upload the image (pnpm r2:upload --slug <name>).
Local Dev Missing Images
# Download all from R2
pnpm r2:download
# Or process from source
pnpm convimgs && pnpm build:metadata
Benefits
| Metric | Before CDN | After CDN | Improvement |
|---|---|---|---|
| Build Size | 4.93GB | ~500MB | -90% |
| Deploy Success Rate | ~60% | ~99% | +65% |
| Build Time | 12-15 min | 3-4 min | -75% |
Migration History
Phase 1: Dual Netlify CDN (Legacy)
The original CDN architecture used two separate Netlify sites:
cdn-media-takazudomodular.netlify.app- General product imagescdn-mercari-takazudomodular.netlify.app- Mercari marketplace images
Phase 2: Cloudflare R2 (Current)
Migrated to a single Cloudflare R2 bucket (zmodmedia) to:
- Eliminate dual-CDN complexity
- Remove egress fees
- Leverage Cloudflare’s global edge network
- Enable easy download for fresh clones
Status: Complete and operational
Phase 3: Media Branch Retirement (Complete)
R2 has been verified in production. The legacy media-branch infrastructure has been retired:
- ✅
mediabranch deleted (no longer needed for image hosting) - ✅
media-deploy.ymlGitHub Actions workflow removed - ✅ LFS tracking rules removed from
.gitattributes(file deleted in commitaadefc46b) - ✅
cache-maintenance.ymlremoved - ⚠️ Dual Netlify CDN site configurations: cleanup in repo Secrets / Netlify dashboard (manual)