assets/diagrams/direct-upload-workflow.md
# Direct Creator Upload Workflow
Visual architecture for Cloudflare Images Direct Creator Upload pattern (frontend + backend).
## Workflow Diagram
```mermaid
sequenceDiagram
actor User
participant Browser
participant Backend as Your Backend<br/>(API/Worker)
participant CF_API as Cloudflare Images API
participant CF_Upload as Cloudflare Upload Endpoint
participant CF_CDN as Cloudflare CDN
Note over User,CF_CDN: Phase 1: User Initiates Upload
User->>Browser: Select image file
Browser->>Browser: Validate file<br/>(size, type)
Note over User,CF_CDN: Phase 2: Request One-Time Upload URL
Browser->>Backend: POST /api/upload-url
Backend->>CF_API: POST /accounts/{id}/images/v2/direct_upload
Note right of CF_API: Generate one-time URL<br/>Valid for 30 minutes
CF_API-->>Backend: {uploadURL, imageId}
Backend-->>Browser: {uploadURL, imageId}
Note over User,CF_CDN: Phase 3: Upload to Cloudflare
Browser->>CF_Upload: POST uploadURL<br/>multipart/form-data<br/>(file)
Note right of CF_Upload: Process image<br/>Generate variants<br/>Store in edge storage
CF_Upload-->>Browser: 200 OK
Note over User,CF_CDN: Phase 4: Display Uploaded Image
Browser->>Browser: Show success<br/>Store imageId
Browser->>CF_CDN: GET /imagedelivery.net/<br/>{hash}/{imageId}/public
Note right of CF_CDN: Serve optimized image<br/>(WebP/AVIF auto)
CF_CDN-->>Browser: Image (cached)
Browser->>User: Display image
Note over User,CF_CDN: Optional: Webhook Notification
CF_Upload->>Backend: POST /webhook<br/>(image.uploaded event)
Note left of Backend: Verify signature<br/>Save to database<br/>Trigger processing
Backend-->>CF_Upload: 200 OK
```
## Key Benefits
1. **Secure**: Upload URLs are one-time use, expire after 30 minutes
2. **Scalable**: Direct upload to Cloudflare edge, no backend bottleneck
3. **Fast**: Parallel upload + CDN delivery
4. **Reliable**: Cloudflare handles all image processing
## Implementation Steps
### 1. Backend: Generate Upload URL
```typescript
// POST /api/upload-url
const response = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${accountId}/images/v2/direct_upload`,
{
method: 'POST',
headers: { 'Authorization': `Bearer ${apiToken}` },
body: JSON.stringify({ requireSignedURLs: false })
}
);
const { uploadURL, id } = (await response.json()).result;
return { uploadURL, imageId: id };
```
### 2. Frontend: Upload to Cloudflare
```typescript
// Get upload URL from backend
const { uploadURL, imageId } = await fetch('/api/upload-url', {
method: 'POST'
}).then(r => r.json());
// Upload file directly to Cloudflare
const formData = new FormData();
formData.append('file', file);
await fetch(uploadURL, {
method: 'POST',
body: formData
});
// Display uploaded image
const imageUrl = `https://imagedelivery.net/${accountHash}/${imageId}/public`;
```
### 3. Backend: Handle Webhook (Optional)
```typescript
// POST /webhook
const signature = request.headers.get('X-Cloudflare-Signature');
const body = await request.text();
// Verify signature
const isValid = await verifySignature(body, signature, webhookSecret);
if (isValid) {
const { image } = JSON.parse(body);
// Save to database, trigger processing, etc.
await db.images.create({ cloudflareId: image.id });
}
```
## Security Considerations
- **Upload URL**: One-time use, expires after 30 minutes
- **CORS**: Configure allowed origins on backend
- **File Validation**: Validate size and type client-side AND server-side
- **Webhook Signature**: Always verify HMAC-SHA256 signature
- **Rate Limiting**: Implement on upload URL generation endpoint
## Performance Optimizations
- **Parallel Upload**: Upload happens directly to Cloudflare edge
- **CDN Caching**: Images cached at edge locations worldwide
- **Format Auto-Negotiation**: WebP/AVIF served automatically
- **Lazy Loading**: Load images as user scrolls
## Error Handling
### Common Errors
1. **Upload URL Expired**: Generate new URL (after 30 minutes)
2. **File Too Large**: Validate < 10MB before upload
3. **Invalid File Type**: Accept only JPEG, PNG, GIF, WebP
4. **CORS Error**: Configure CORS headers on backend
5. **Network Failure**: Implement retry with exponential backoff
### Example Error Handling
```typescript
async function uploadWithRetry(file: File, maxRetries = 3) {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const { uploadURL, imageId } = await getUploadURL();
await uploadToCloudflare(uploadURL, file);
return imageId;
} catch (error) {
if (attempt === maxRetries) throw error;
await delay(Math.pow(2, attempt) * 1000);
}
}
}
```
## Related References
- **Complete Guide**: `references/direct-upload-complete-workflow.md`
- **Backend Template**: `templates/worker-upload.ts`
- **Frontend Template**: `templates/direct-upload-frontend.html`
- **Webhook Handler**: `templates/webhook-handler.ts`
## Related Diagrams
- **Transformation Pipeline**: `diagrams/transformation-pipeline.md`
- **Variants Architecture**: `diagrams/variants-structure.md`
assets/diagrams/transformation-pipeline.md
# Cloudflare Images Transformation Pipeline
Visual architecture showing how Cloudflare Images processes transformation requests using URL-based transformations vs Workers API.
## Transformation Pipeline Diagram
```mermaid
flowchart TB
User[User/Browser]
Request[HTTP Request]
Edge[Cloudflare Edge]
subgraph "URL-Based Transformations"
URL[Image URL with params<br/>?width=800&quality=85&format=webp]
ParseURL[Parse URL Parameters]
ValidateURL[Validate Parameters]
end
subgraph "Workers API Transformations"
Worker[Cloudflare Worker]
BindingAPI[env.IMAGES.get]
ParseWorker[Parse Options Object]
ValidateWorker[Validate Options]
end
subgraph "Cloudflare Images Engine"
Cache{CDN Cache?}
Original[Fetch Original Image]
Transform[Apply Transformations]
subgraph "Transformation Steps"
Resize[1. Resize<br/>width/height/fit]
Quality[2. Quality<br/>compression]
Format[3. Format<br/>WebP/AVIF/JPEG]
Effects[4. Effects<br/>blur/brightness/contrast]
Metadata[5. Metadata<br/>strip/keep/copyright]
end
Optimize[Optimize for Delivery]
Store[Store in Cache]
end
Response[HTTP Response]
User -->|1. Request Image| Request
Request --> Edge
Edge -->|URL Transform| URL
Edge -->|Worker Transform| Worker
URL --> ParseURL
ParseURL --> ValidateURL
ValidateURL --> Cache
Worker --> BindingAPI
BindingAPI --> ParseWorker
ParseWorker --> ValidateWorker
ValidateWorker --> Cache
Cache -->|HIT| Response
Cache -->|MISS| Original
Original --> Transform
Transform --> Resize
Resize --> Quality
Quality --> Format
Format --> Effects
Effects --> Metadata
Metadata --> Optimize
Optimize --> Store
Store --> Response
Response -->|2. Transformed Image| User
style Cache fill:#f9f,stroke:#333,stroke-width:2px
style Transform fill:#bbf,stroke:#333,stroke-width:2px
style Response fill:#bfb,stroke:#333,stroke-width:2px
```
## Two Transformation Methods
### Method 1: URL-Based Transformations (Recommended)
**Use Case**: Frontend image delivery (HTML, React, Vue, etc.)
**Example**:
```html
<img src="https://imagedelivery.net/{hash}/{id}/public?width=800&quality=85&format=auto" />
```
**Pros**:
- Simple to use
- No backend code required
- Automatic caching
- Works in any framework
**Cons**:
- Limited to URL parameters
- Cannot use complex logic
### Method 2: Workers API Transformations
**Use Case**: Backend processing, dynamic transformations, complex logic
**Example**:
```typescript
// In Cloudflare Worker
const image = await env.IMAGES.get(imageId, {
cf: {
image: {
width: 800,
quality: 85,
format: 'auto'
}
}
});
return new Response(image.body, {
headers: { 'Content-Type': 'image/jpeg' }
});
```
**Pros**:
- Programmatic control
- Can use complex logic
- Integrate with auth, watermarking, etc.
**Cons**:
- Requires Cloudflare Worker
- More complex setup
## Transformation Parameters
### Dimensions
```
width: 1-9999 pixels
height: 1-9999 pixels
```
### Fit Modes
```mermaid
flowchart LR
Original[Original Image<br/>1000x600]
subgraph "fit=scale-down"
SD[800x480<br/>Never enlarge]
end
subgraph "fit=contain"
Contain[800x480<br/>Fit within box]
end
subgraph "fit=cover"
Cover[800x800<br/>Cover box, crop]
end
subgraph "fit=crop"
Crop[800x800<br/>Exact crop]
end
subgraph "fit=pad"
Pad[800x800<br/>Fit + pad]
end
Original --> SD
Original --> Contain
Original --> Cover
Original --> Crop
Original --> Pad
```
### Quality
```
quality: 1-100
- 60-70: High compression (visible artifacts)
- 80-85: Optimal (recommended)
- 90-95: High quality (larger file)
- 100: No compression (not recommended)
```
### Format
```
format: auto | webp | avif | jpeg | png
- auto: WebP/AVIF based on Accept header (recommended)
- webp: 25-35% smaller than JPEG
- avif: 50% smaller than JPEG
- jpeg: Universal compatibility
- png: Transparency support
```
### Effects
```
blur: 1-250 pixels
brightness: -100 to 100
contrast: -100 to 100
gamma: 0.1 to 2.0
```
## Caching Strategy
```mermaid
flowchart TB
Request[Request with Params]
CacheKey[Generate Cache Key<br/>hash of URL + params]
Check{Check CDN Cache}
subgraph "Cache Hit Path"
EdgeCache[Edge Cache HIT]
Serve[Serve from Cache<br/>~10ms]
end
subgraph "Cache Miss Path"
Origin[Fetch Original]
Transform[Transform Image]
Store[Store in Cache<br/>TTL: ~30 days]
end
Response[Return Image]
Request --> CacheKey
CacheKey --> Check
Check -->|HIT| EdgeCache
EdgeCache --> Serve
Serve --> Response
Check -->|MISS| Origin
Origin --> Transform
Transform --> Store
Store --> Response
style EdgeCache fill:#bfb,stroke:#333,stroke-width:2px
style Transform fill:#bbf,stroke:#333,stroke-width:2px
```
### Cache Behavior
- **Cache Key**: URL + all transformation parameters
- **TTL**: ~30 days for transformed images
- **Purge**: Update original → invalidates all variants
- **Global**: Cached at 300+ edge locations worldwide
## Performance Optimization Tips
### 1. Use Format Auto-Negotiation
```html
<!-- Automatically serves WebP/AVIF based on browser -->
<img src="...?format=auto" />
```
**Savings**: 25-50% file size reduction
### 2. Set Appropriate Quality
```html
<!-- Thumbnails: Lower quality acceptable -->
<img src="...?quality=80" />
<!-- Hero images: Higher quality -->
<img src="...?quality=90" />
```
**Savings**: 30-50% file size at quality=85 vs 100
### 3. Use Responsive Images
```html
<img
srcset="
...?width=400 400w,
...?width=800 800w,
...?width=1200 1200w
"
sizes="(max-width: 640px) 100vw, 800px"
/>
```
**Savings**: Only load appropriate size for device
### 4. Lazy Load Below-Fold Images
```html
<img src="..." loading="lazy" />
```
**Savings**: Defer loading until needed
## Error Codes
### Transformation Errors (9400-9413)
```
9401: Invalid width (must be 1-9999)
9402: Invalid height (must be 1-9999)
9403: Invalid fit (must be scale-down/contain/cover/crop/pad)
9404: Invalid quality (must be 1-100)
9406: Invalid background color (must be hex format)
9408: Invalid trim value
9411: Invalid rotation (must be 90/180/270/auto)
9412: Invalid brightness (-100 to 100)
9413: Invalid contrast (-100 to 100)
```
### Resolution
Load `references/top-errors.md` for complete error solutions.
## Implementation Examples
### React Component
```typescript
interface CloudflareImageProps {
imageId: string;
width?: number;
quality?: number;
format?: 'auto' | 'webp' | 'avif' | 'jpeg';
}
export function CloudflareImage({
imageId,
width = 800,
quality = 85,
format = 'auto'
}: CloudflareImageProps) {
const params = new URLSearchParams({
width: width.toString(),
quality: quality.toString(),
format
});
const url = `https://imagedelivery.net/${ACCOUNT_HASH}/${imageId}/public?${params}`;
return <img src={url} alt="" loading="lazy" />;
}
```
### Cloudflare Worker
```typescript
export default {
async fetch(request: Request, env: Env) {
const url = new URL(request.url);
const imageId = url.pathname.slice(1);
// Dynamic transformation based on request
const isMobile = /mobile/i.test(request.headers.get('user-agent') || '');
const image = await env.IMAGES.get(imageId, {
cf: {
image: {
width: isMobile ? 400 : 800,
quality: isMobile ? 80 : 85,
format: 'auto'
}
}
});
return new Response(image.body, {
headers: {
'Content-Type': 'image/jpeg',
'Cache-Control': 'public, max-age=31536000'
}
});
}
};
```
## Related References
- **Transformation Options**: `references/transformation-options.md`
- **Format Optimization**: `references/format-optimization.md`
- **Top Errors**: `references/top-errors.md`
- **API Reference**: `references/api-reference.md`
## Related Diagrams
- **Direct Upload Workflow**: `diagrams/direct-upload-workflow.md`
- **Variants Architecture**: `diagrams/variants-structure.md`
assets/diagrams/variants-structure.md
# Cloudflare Images Variants Architecture
Visual architecture comparing Named Variants vs Flexible Transformations, including variant configuration, management, and usage patterns.
## Variants Architecture Overview
```mermaid
flowchart TB
Upload[Image Upload]
subgraph "Cloudflare Images Storage"
Original[Original Image<br/>Stored Once]
end
subgraph "Named Variants"
Config[Variant Configuration<br/>Max 100 variants]
V1[thumbnail<br/>300x300 cover q=80]
V2[medium<br/>800x800 scale-down q=85]
V3[large<br/>1600x1600 scale-down q=90]
V4[avatar-sm<br/>48x48 cover q=80]
V5[Custom variants...]
Config --> V1
Config --> V2
Config --> V3
Config --> V4
Config --> V5
end
subgraph "Flexible Transformations"
URL1[URL: ?width=400&quality=80]
URL2[URL: ?width=800&quality=85&format=webp]
URL3[URL: ?width=1200&fit=cover&blur=20]
URL4[Any combination...]
end
subgraph "Delivery URLs"
Named1[imagedelivery.net/{hash}/{id}/thumbnail]
Named2[imagedelivery.net/{hash}/{id}/medium]
Flex1[imagedelivery.net/{hash}/{id}/public?width=400]
Flex2[imagedelivery.net/{hash}/{id}/public?width=800&fit=cover]
end
Upload --> Original
Original --> Config
Original --> URL1
V1 -.-> Named1
V2 -.-> Named2
URL1 -.-> Flex1
URL2 -.-> Flex2
style Original fill:#bbf,stroke:#333,stroke-width:2px
style Config fill:#f9f,stroke:#333,stroke-width:2px
style Named1 fill:#bfb,stroke:#333,stroke-width:2px
style Flex1 fill:#fbf,stroke:#333,stroke-width:2px
```
## Named Variants vs Flexible Transformations
### Named Variants
**Use Case**: Pre-defined sizes you use frequently
**Pros**:
- ✅ Shorter URLs
- ✅ Consistent sizing across app
- ✅ Easier to manage centrally
- ✅ Can require signed URLs per-variant
**Cons**:
- ❌ Limited to 100 variants max
- ❌ Requires API call to create
- ❌ Less flexible (fixed parameters)
**Example**:
```html
<!-- Named variant -->
<img src="https://imagedelivery.net/{hash}/{id}/thumbnail" />
```
### Flexible Transformations
**Use Case**: Dynamic transformations, one-off sizes
**Pros**:
- ✅ Unlimited combinations
- ✅ No setup required
- ✅ Dynamic parameters
- ✅ Great for responsive images
**Cons**:
- ❌ Longer URLs
- ❌ Potential for parameter misuse
- ❌ Harder to enforce consistency
**Example**:
```html
<!-- Flexible transformation -->
<img src="https://imagedelivery.net/{hash}/{id}/public?width=300&height=300&fit=cover&quality=80" />
```
## Variant Configuration Workflow
```mermaid
sequenceDiagram
actor Admin
participant Dashboard as Cloudflare Dashboard
participant API as Cloudflare API
participant Storage as Variant Storage
participant CDN as CDN Edge
Note over Admin,CDN: Phase 1: Create Variant
Admin->>Dashboard: Define variant<br/>(name, dimensions, options)
Dashboard->>API: POST /variants<br/>{id, options}
API->>Storage: Store configuration
Storage-->>API: Variant created
API-->>Dashboard: Success
Dashboard-->>Admin: Variant ready
Note over Admin,CDN: Phase 2: Use Variant
Admin->>CDN: Request image<br/>/{hash}/{id}/variant-name
CDN->>Storage: Lookup variant config
Storage-->>CDN: {width, height, fit, quality}
CDN->>CDN: Apply transformations<br/>Cache result
CDN-->>Admin: Transformed image
Note over Admin,CDN: Phase 3: Update Variant
Admin->>Dashboard: Modify variant options
Dashboard->>API: PATCH /variants/{id}
API->>Storage: Update configuration
API->>CDN: Purge variant cache
CDN-->>Admin: New version served
```
## Variant Limit Management
```mermaid
flowchart TB
Start[Start: Need New Variant]
Check{Variants < 100?}
Create[Create New Variant]
Audit[Audit Existing Variants]
subgraph "Cleanup Options"
Delete[Delete Unused Variants]
Merge[Merge Similar Variants]
Flexible[Use Flexible Transform]
end
Success[Variant Created]
Alternative[Use Alternative]
Start --> Check
Check -->|Yes| Create
Check -->|No| Audit
Create --> Success
Audit --> Delete
Audit --> Merge
Audit --> Flexible
Delete --> Create
Merge --> Create
Flexible --> Alternative
style Check fill:#f9f,stroke:#333,stroke-width:2px
style Success fill:#bfb,stroke:#333,stroke-width:2px
style Alternative fill:#fbf,stroke:#333,stroke-width:2px
```
### Variant Limit Best Practices
**100 Variant Limit**: Plan carefully
1. **Common Sizes Only**: Create variants for 80% use cases
2. **Flexible for Edge Cases**: Use URL params for one-off sizes
3. **Regular Audit**: Delete unused variants
4. **Naming Convention**: Consistent naming (e.g., `product-sm`, `product-md`, `product-lg`)
## Recommended Variant Sets
### E-Commerce Product Images
```json
{
"product-thumb": { "width": 150, "height": 150, "fit": "cover", "quality": 80 },
"product-sm": { "width": 300, "height": 300, "fit": "cover", "quality": 85 },
"product-md": { "width": 600, "height": 600, "fit": "scale-down", "quality": 85 },
"product-lg": { "width": 1200, "height": 1200, "fit": "scale-down", "quality": 90 },
"product-zoom": { "width": 2400, "height": 2400, "fit": "scale-down", "quality": 95 }
}
```
**Variants Used**: 5/100
### User Avatars
```json
{
"avatar-xs": { "width": 24, "height": 24, "fit": "cover", "quality": 75 },
"avatar-sm": { "width": 48, "height": 48, "fit": "cover", "quality": 80 },
"avatar-md": { "width": 96, "height": 96, "fit": "cover", "quality": 85 },
"avatar-lg": { "width": 192, "height": 192, "fit": "cover", "quality": 85 },
"avatar-xl": { "width": 384, "height": 384, "fit": "cover", "quality": 90 }
}
```
**Variants Used**: 5/100
### Blog/Content Images
```json
{
"content-thumb": { "width": 400, "height": 225, "fit": "cover", "quality": 80 },
"content-mobile": { "width": 768, "fit": "scale-down", "quality": 85 },
"content-tablet": { "width": 1024, "fit": "scale-down", "quality": 85 },
"content-desktop": { "width": 1600, "fit": "scale-down", "quality": 90 }
}
```
**Variants Used**: 4/100
### Hero/Banner Images
```json
{
"hero-mobile": { "width": 768, "height": 432, "fit": "cover", "quality": 85 },
"hero-tablet": { "width": 1024, "height": 576, "fit": "cover", "quality": 90 },
"hero-desktop": { "width": 1920, "height": 1080, "fit": "cover", "quality": 90 },
"hero-4k": { "width": 3840, "height": 2160, "fit": "cover", "quality": 95 }
}
```
**Variants Used**: 4/100
## Variant Management API
### Create Variant
```bash
curl -X POST \
"https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/images/v1/variants" \
-H "Authorization: Bearer ${CF_API_TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"id": "thumbnail",
"options": {
"width": 300,
"height": 300,
"fit": "cover",
"metadata": "none"
},
"neverRequireSignedURLs": true
}'
```
### List Variants
```bash
curl -X GET \
"https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/images/v1/variants" \
-H "Authorization: Bearer ${CF_API_TOKEN}"
```
### Update Variant
```bash
curl -X PATCH \
"https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/images/v1/variants/thumbnail" \
-H "Authorization: Bearer ${CF_API_TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"options": {
"width": 350,
"height": 350,
"fit": "cover",
"quality": 85
}
}'
```
### Delete Variant
```bash
curl -X DELETE \
"https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/images/v1/variants/thumbnail" \
-H "Authorization: Bearer ${CF_API_TOKEN}"
```
## Variant Usage Patterns
### Pattern 1: Named Variants with srcset
```html
<img
src="https://imagedelivery.net/{hash}/{id}/product-md"
srcset="
https://imagedelivery.net/{hash}/{id}/product-sm 300w,
https://imagedelivery.net/{hash}/{id}/product-md 600w,
https://imagedelivery.net/{hash}/{id}/product-lg 1200w
"
sizes="(max-width: 640px) 100vw, 600px"
alt="Product"
/>
```
### Pattern 2: Flexible Transformations with srcset
```html
<img
src="https://imagedelivery.net/{hash}/{id}/public?width=600"
srcset="
https://imagedelivery.net/{hash}/{id}/public?width=300 300w,
https://imagedelivery.net/{hash}/{id}/public?width=600 600w,
https://imagedelivery.net/{hash}/{id}/public?width=1200 1200w
"
sizes="(max-width: 640px) 100vw, 600px"
alt="Product"
/>
```
### Pattern 3: Hybrid Approach (Recommended)
```typescript
// Named variants for common sizes
const VARIANTS = {
thumbnail: 'product-thumb',
small: 'product-sm',
medium: 'product-md',
large: 'product-lg'
};
// Flexible for one-off sizes
function getImageUrl(imageId: string, size: number | keyof typeof VARIANTS) {
const baseUrl = `https://imagedelivery.net/${ACCOUNT_HASH}/${imageId}`;
if (typeof size === 'string' && size in VARIANTS) {
// Use named variant
return `${baseUrl}/${VARIANTS[size]}`;
} else {
// Use flexible transformation
return `${baseUrl}/public?width=${size}&quality=85&format=auto`;
}
}
// Usage
<img src={getImageUrl(imageId, 'medium')} /> // Named variant
<img src={getImageUrl(imageId, 450)} /> // Flexible transform
```
## Variant Caching Behavior
```mermaid
flowchart LR
Request[Request]
subgraph "CDN Cache"
VarCache[Variant Cache<br/>TTL ~30 days]
end
subgraph "Variant Processing"
Lookup[Lookup Config]
Transform[Apply Transform]
Store[Store Result]
end
Response[Response]
Request --> VarCache
VarCache -->|HIT| Response
VarCache -->|MISS| Lookup
Lookup --> Transform
Transform --> Store
Store --> Response
style VarCache fill:#bfb,stroke:#333,stroke-width:2px
style Transform fill:#bbf,stroke:#333,stroke-width:2px
```
### Cache Keys
- **Named Variant**: `{imageId}/{variantName}`
- **Flexible**: `{imageId}/public?{sortedParams}`
### Cache Invalidation
- **Update Variant**: Purges all cached images for that variant
- **Delete Original**: Purges all variants
- **Manual Purge**: API endpoint available
## Related References
- **Variants Guide**: `references/variants-guide.md`
- **Transformation Options**: `references/transformation-options.md`
- **Responsive Images**: `references/responsive-images-patterns.md`
- **API Reference**: `references/api-reference.md`
## Related Diagrams
- **Direct Upload Workflow**: `diagrams/direct-upload-workflow.md`
- **Transformation Pipeline**: `diagrams/transformation-pipeline.md`
## Related Commands
- **Generate Variant**: `/generate-variant` - Interactive variant creator
- **Check Images**: `/check-images` - List all configured variants
examples/basic-upload/.env.example
# Cloudflare Images Configuration
# Copy this file to .env and fill in your credentials
CF_ACCOUNT_ID=your_account_id_here
CF_API_TOKEN=your_api_token_here
CF_ACCOUNT_HASH=your_account_hash_here
# Get your credentials:
# - Account ID: Dashboard → Workers & Pages → Account ID (right sidebar)
# - API Token: Dashboard → My Profile → API Tokens → Create Token → "Edit Cloudflare Images"
# - Account Hash: Dashboard → Images → Serving Images → Account Hash
examples/basic-upload/package.json
{
"name": "cloudflare-images-basic-upload",
"version": "1.0.0",
"description": "Minimal example of uploading to Cloudflare Images",
"main": "src/index.ts",
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"tail": "wrangler tail"
},
"keywords": [
"cloudflare",
"images",
"upload",
"workers"
],
"author": "",
"license": "MIT",
"devDependencies": {
"@cloudflare/workers-types": "^4.20260408.0",
"wrangler": "^4.81.0"
},
"dependencies": {
"hono": "^4.12.12"
}
}
examples/basic-upload/public/index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cloudflare Images - Basic Upload Example</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.container {
background: white;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
padding: 40px;
max-width: 600px;
width: 100%;
}
h1 {
color: #333;
margin-bottom: 10px;
font-size: 28px;
}
.subtitle {
color: #666;
margin-bottom: 30px;
font-size: 14px;
}
.upload-form {
margin-bottom: 30px;
}
.file-input-wrapper {
position: relative;
margin-bottom: 20px;
}
input[type="file"] {
width: 100%;
padding: 15px;
border: 2px dashed #ddd;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
transition: border-color 0.3s;
}
input[type="file"]:hover {
border-color: #667eea;
}
.upload-button {
width: 100%;
padding: 15px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s, opacity 0.3s;
}
.upload-button:hover:not(:disabled) {
transform: translateY(-2px);
}
.upload-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.progress-container {
display: none;
margin-bottom: 20px;
}
.progress-bar {
width: 100%;
height: 8px;
background: #f0f0f0;
border-radius: 4px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
width: 0%;
transition: width 0.3s;
}
.progress-text {
margin-top: 8px;
color: #666;
font-size: 14px;
text-align: center;
}
.error {
background: #fee;
color: #c33;
padding: 15px;
border-radius: 8px;
margin-bottom: 20px;
border-left: 4px solid #c33;
display: none;
}
.success {
background: #efe;
color: #3c3;
padding: 15px;
border-radius: 8px;
margin-bottom: 20px;
border-left: 4px solid #3c3;
display: none;
}
.uploaded-image-container {
display: none;
text-align: center;
}
.uploaded-image-container h2 {
color: #333;
margin-bottom: 15px;
font-size: 20px;
}
.uploaded-image {
max-width: 100%;
border-radius: 8px;
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
}
.image-details {
margin-top: 15px;
padding: 15px;
background: #f8f8f8;
border-radius: 8px;
text-align: left;
font-size: 13px;
color: #666;
}
.image-details div {
margin: 5px 0;
}
.image-details strong {
color: #333;
}
</style>
</head>
<body>
<div class="container">
<h1>🖼️ Cloudflare Images Upload</h1>
<p class="subtitle">Basic Upload Example - Direct Creator Upload Pattern</p>
<div class="error" id="error"></div>
<div class="success" id="success"></div>
<form class="upload-form" id="upload-form">
<div class="file-input-wrapper">
<input
type="file"
id="file-input"
accept="image/jpeg,image/png,image/webp,image/gif"
required
/>
</div>
<div class="progress-container" id="progress-container">
<div class="progress-bar">
<div class="progress-fill" id="progress-fill"></div>
</div>
<div class="progress-text" id="progress-text">Uploading... 0%</div>
</div>
<button type="submit" class="upload-button" id="upload-button">
Upload Image
</button>
</form>
<div class="uploaded-image-container" id="uploaded-image-container">
<h2>✅ Upload Successful!</h2>
<img class="uploaded-image" id="uploaded-image" alt="Uploaded image">
<div class="image-details" id="image-details"></div>
</div>
</div>
<script>
// Configuration
const API_URL = 'http://localhost:8787'; // Change to your Worker URL in production
const ACCOUNT_HASH = 'your_account_hash_here'; // Replace with your actual account hash
// DOM elements
const form = document.getElementById('upload-form');
const fileInput = document.getElementById('file-input');
const uploadButton = document.getElementById('upload-button');
const progressContainer = document.getElementById('progress-container');
const progressFill = document.getElementById('progress-fill');
const progressText = document.getElementById('progress-text');
const errorDiv = document.getElementById('error');
const successDiv = document.getElementById('success');
const uploadedImageContainer = document.getElementById('uploaded-image-container');
const uploadedImage = document.getElementById('uploaded-image');
const imageDetails = document.getElementById('image-details');
// Form submit handler
form.addEventListener('submit', async (e) => {
e.preventDefault();
await uploadImage();
});
async function uploadImage() {
// Get selected file
const file = fileInput.files[0];
if (!file) {
showError('Please select a file');
return;
}
// Validate file size (max 10MB)
const maxSize = 10 * 1024 * 1024;
if (file.size > maxSize) {
showError(`File too large (${(file.size / 1024 / 1024).toFixed(1)}MB). Maximum size is 10MB.`);
return;
}
// Validate file type
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
if (!allowedTypes.includes(file.type)) {
showError('Invalid file type. Please upload JPEG, PNG, WebP, or GIF.');
return;
}
try {
// Reset UI
hideError();
hideSuccess();
uploadedImageContainer.style.display = 'none';
// Disable form
uploadButton.disabled = true;
fileInput.disabled = true;
// Show progress
showProgress(10);
// Step 1: Get one-time upload URL from our Worker
console.log('Requesting upload URL from Worker...');
const urlResponse = await fetch(`${API_URL}/api/upload-url`, {
method: 'POST'
});
if (!urlResponse.ok) {
throw new Error(`Failed to get upload URL: ${urlResponse.statusText}`);
}
const { uploadURL, imageId } = await urlResponse.json();
console.log('Got upload URL. Image ID:', imageId);
showProgress(30);
// Step 2: Upload file directly to Cloudflare Images
console.log('Uploading file to Cloudflare...');
const uploadFormData = new FormData();
uploadFormData.append('file', file);
const xhr = new XMLHttpRequest();
// Track upload progress
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const percentComplete = 30 + Math.round((e.loaded / e.total) * 60);
showProgress(percentComplete);
}
});
// Handle upload completion
const uploadPromise = new Promise((resolve, reject) => {
xhr.addEventListener('load', () => {
if (xhr.status === 200) {
resolve();
} else {
reject(new Error(`Upload failed: ${xhr.statusText}`));
}
});
xhr.addEventListener('error', () => {
reject(new Error('Network error during upload'));
});
});
xhr.open('POST', uploadURL);
xhr.send(uploadFormData);
await uploadPromise;
showProgress(100);
console.log('Upload successful!');
// Step 3: Display uploaded image
const imageUrl = `https://imagedelivery.net/${ACCOUNT_HASH}/${imageId}/public`;
uploadedImage.src = imageUrl;
// Build details with the DOM API rather than innerHTML: file.name
// is user-controlled, so interpolating it into innerHTML would be
// DOM XSS. textContent never parses as HTML, so it is safe here.
imageDetails.replaceChildren();
const detailRows = [
['Image ID', imageId],
['Filename', file.name],
['Size', `${(file.size / 1024).toFixed(1)} KB`],
['Type', file.type],
];
for (const [label, value] of detailRows) {
const row = document.createElement('div');
const strong = document.createElement('strong');
strong.textContent = `${label}:`;
row.append(strong, ' ', value);
imageDetails.append(row);
}
const urlRow = document.createElement('div');
const urlLabel = document.createElement('strong');
urlLabel.textContent = 'URL:';
const urlLink = document.createElement('a');
urlLink.href = imageUrl;
urlLink.target = '_blank';
urlLink.textContent = imageUrl;
urlRow.append(urlLabel, ' ', urlLink);
imageDetails.append(urlRow);
uploadedImageContainer.style.display = 'block';
showSuccess('Image uploaded successfully!');
// Reset form
form.reset();
} catch (error) {
console.error('Upload error:', error);
showError(error.message);
} finally {
// Re-enable form
uploadButton.disabled = false;
fileInput.disabled = false;
hideProgress();
}
}
// UI helper functions
function showProgress(percent) {
progressContainer.style.display = 'block';
progressFill.style.width = `${percent}%`;
progressText.textContent = `Uploading... ${percent}%`;
}
function hideProgress() {
setTimeout(() => {
progressContainer.style.display = 'none';
progressFill.style.width = '0%';
}, 500);
}
function showError(message) {
errorDiv.textContent = message;
errorDiv.style.display = 'block';
}
function hideError() {
errorDiv.style.display = 'none';
}
function showSuccess(message) {
successDiv.textContent = message;
successDiv.style.display = 'block';
}
function hideSuccess() {
successDiv.style.display = 'none';
}
</script>
</body>
</html>
examples/basic-upload/README.md
# Basic Upload Example
Minimal but complete example of uploading images to Cloudflare Images using Direct Creator Upload pattern.
## Features
- ✅ Direct Creator Upload (frontend → Cloudflare, no backend bottleneck)
- ✅ Cloudflare Worker backend for generating upload URLs
- ✅ Simple HTML/JavaScript frontend
- ✅ File validation (size, type)
- ✅ Upload progress tracking
- ✅ Error handling
- ✅ Success state with image display
## Project Structure
```
basic-upload/
├── README.md # This file
├── package.json # Dependencies
├── wrangler.jsonc # Cloudflare Worker config
├── .env.example # Example environment variables
├── src/
│ └── index.ts # Worker: Upload URL generation
└── public/
└── index.html # Frontend: Upload form
```
## Prerequisites
- Node.js 20+ installed
- Cloudflare account with Images enabled
- Wrangler CLI: `npm install -g wrangler`
## Setup
### 1. Install Dependencies
```bash
npm install
```
### 2. Configure Environment Variables
```bash
cp .env.example .env
```
Edit `.env` and add your Cloudflare credentials:
```bash
CF_ACCOUNT_ID=your_account_id_here
CF_API_TOKEN=your_api_token_here
CF_ACCOUNT_HASH=your_account_hash_here
```
**Get your credentials:**
- **Account ID**: Cloudflare Dashboard → Workers & Pages → Account ID
- **API Token**: Dashboard → My Profile → API Tokens → Create Token → "Edit Cloudflare Images"
- **Account Hash**: Dashboard → Images → Serving Images → Account Hash
### 3. Update wrangler.jsonc
Edit `wrangler.jsonc` and replace `YOUR_ACCOUNT_ID` with your actual account ID:
```json
{
"account_id": "your_account_id_here"
}
```
### 4. Run Locally
```bash
wrangler dev
```
This starts the Worker at `http://localhost:8787`
### 5. Open Frontend
Open `public/index.html` in your browser (or serve it locally):
```bash
# Option 1: Open directly
open public/index.html
# Option 2: Use a local server
npx serve public
```
### 6. Test Upload
1. Click "Choose File" and select an image
2. Click "Upload Image"
3. Watch progress bar (0-100%)
4. See uploaded image displayed on success
## How It Works
### Architecture
```
Browser Worker Cloudflare Images
| | |
|---(1) Request URL------>| |
| |---(2) Generate URL-------->|
| |<---(3) {uploadURL, id}-----|
|<---(4) Return URL-------| |
| |
|--------------(5) Upload File to uploadURL----------->|
|<-------------(6) 200 OK-------------------------------|
| |
|---(7) Display image from imagedelivery.net---------->|
```
### Step-by-Step
1. **User selects file**: Frontend validates size (<10MB) and type (JPEG/PNG/WebP/GIF)
2. **Frontend requests upload URL**: `POST http://localhost:8787/api/upload-url`
3. **Worker generates one-time URL**: Calls Cloudflare Images API `/direct_upload`
4. **Worker returns URL to frontend**: `{uploadURL, imageId}`
5. **Frontend uploads directly to Cloudflare**: `POST uploadURL` with `multipart/form-data`
6. **Cloudflare processes image**: Stores, generates variants, caches
7. **Frontend displays image**: Fetches from `imagedelivery.net`
### Key Code
**Worker (src/index.ts):**
```typescript
// Generate one-time upload URL
app.post('/api/upload-url', async (c) => {
const response = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${c.env.CF_ACCOUNT_ID}/images/v2/direct_upload`,
{
method: 'POST',
headers: { 'Authorization': `Bearer ${c.env.CF_API_TOKEN}` }
}
);
const result = await response.json();
return c.json({
uploadURL: result.result.uploadURL,
imageId: result.result.id
});
});
```
**Frontend (public/index.html):**
```javascript
// Get upload URL
const { uploadURL, imageId } = await fetch('http://localhost:8787/api/upload-url', {
method: 'POST'
}).then(r => r.json());
// Upload to Cloudflare
const formData = new FormData();
formData.append('file', file);
await fetch(uploadURL, {
method: 'POST',
body: formData
});
// Display uploaded image
img.src = `https://imagedelivery.net/${ACCOUNT_HASH}/${imageId}/public`;
```
## Deploy to Production
### 1. Deploy Worker
```bash
wrangler deploy
```
This deploys your Worker to `https://basic-upload.YOUR_SUBDOMAIN.workers.dev`
### 2. Update Frontend
Edit `public/index.html` and replace `http://localhost:8787` with your Worker URL:
```javascript
const API_URL = 'https://basic-upload.YOUR_SUBDOMAIN.workers.dev';
```
### 3. Deploy Frontend
Deploy `public/index.html` to:
- Cloudflare Pages
- Vercel
- Netlify
- Any static hosting
## Troubleshooting
### Error: CORS Policy
**Symptom**: "CORS policy: No 'Access-Control-Allow-Origin' header"
**Solution**: CORS headers are already configured in Worker. Ensure:
- Frontend is served from same origin as Worker, OR
- Update CORS origins in `src/index.ts`
### Error: Upload URL Expired
**Symptom**: Upload fails after waiting
**Solution**: Upload URLs expire after 30 minutes. Generate new URL for each upload.
### Error: File Too Large
**Symptom**: "File too large" error
**Solution**: Cloudflare Images max file size is 10MB. Compress image before upload.
### Error: Invalid File Type
**Symptom**: Upload rejected
**Solution**: Only JPEG, PNG, WebP, GIF supported. Convert other formats.
## Next Steps
### Add Features
- **Webhook**: Handle upload notifications (`templates/webhook-handler.ts`)
- **Database**: Store image metadata (Drizzle ORM + D1)
- **Variants**: Create named variants (`/generate-variant`)
- **Signed URLs**: Private images (`references/signed-urls-guide.md`)
- **Watermarks**: Add branding (`templates/overlay-watermark.ts`)
### Production Checklist
- [ ] Environment variables in Wrangler secrets (not `.env`)
- [ ] CORS origins restricted to your domain
- [ ] Rate limiting on upload URL generation
- [ ] File validation server-side (not just client-side)
- [ ] Error logging and monitoring
- [ ] CDN caching configured
- [ ] Variants created for common sizes
## Related Examples
- **Responsive Gallery**: Complete gallery with responsive images
- **Private Images**: Signed URLs for access control
## Related References
- **Direct Upload Guide**: `references/direct-upload-complete-workflow.md`
- **API Reference**: `references/api-reference.md`
- **Worker Template**: `templates/worker-upload.ts`
examples/basic-upload/src/index.ts
/**
* Basic Upload Example - Cloudflare Worker
*
* Generates one-time upload URLs for Direct Creator Upload pattern.
*/
import { Hono } from 'hono';
import { cors } from 'hono/cors';
interface Env {
CF_ACCOUNT_ID: string;
CF_API_TOKEN: string;
CF_ACCOUNT_HASH: string;
}
const app = new Hono<{ Bindings: Env }>();
// CORS configuration - Allow frontend to call API
app.use('/*', cors({
origin: ['http://localhost:8787', 'http://localhost:3000', 'http://localhost:5173'],
allowMethods: ['GET', 'POST', 'OPTIONS'],
allowHeaders: ['Content-Type'],
credentials: true
}));
/**
* Health check endpoint
*/
app.get('/', (c) => {
return c.json({
status: 'ok',
service: 'Cloudflare Images Basic Upload Example',
endpoints: {
uploadUrl: 'POST /api/upload-url',
health: 'GET /'
}
});
});
/**
* Generate one-time upload URL
*
* POST /api/upload-url
*
* Returns:
* {
* "uploadURL": "https://upload.imagedelivery.net/...",
* "imageId": "2cdc28f0-017a-49c4-9ed7-87056c83901"
* }
*/
app.post('/api/upload-url', async (c) => {
try {
// Verify environment variables are set
if (!c.env.CF_ACCOUNT_ID || !c.env.CF_API_TOKEN) {
return c.json({
error: 'Missing configuration',
message: 'CF_ACCOUNT_ID and CF_API_TOKEN must be set'
}, 500);
}
console.log('Generating upload URL for account:', c.env.CF_ACCOUNT_ID.substring(0, 8) + '...');
// Request one-time upload URL from Cloudflare Images API
const response = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${c.env.CF_ACCOUNT_ID}/images/v2/direct_upload`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${c.env.CF_API_TOKEN}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
requireSignedURLs: false,
metadata: {
source: 'basic-upload-example',
timestamp: new Date().toISOString()
}
})
}
);
const result = await response.json<any>();
if (!result.success) {
console.error('Cloudflare API error:', result.errors);
return c.json({
error: 'Failed to generate upload URL',
details: result.errors
}, 500);
}
console.log('Upload URL generated successfully. Image ID:', result.result.id);
// Return upload URL and image ID to frontend
return c.json({
uploadURL: result.result.uploadURL,
imageId: result.result.id
});
} catch (error) {
console.error('Error generating upload URL:', error);
return c.json({
error: 'Internal server error',
message: error instanceof Error ? error.message : 'Unknown error'
}, 500);
}
});
export default app;
examples/basic-upload/wrangler.jsonc
{
"name": "cloudflare-images-basic-upload",
"main": "src/index.ts",
"compatibility_date": "2025-01-15",
"account_id": "YOUR_ACCOUNT_ID",
// Environment variables (for local development)
"vars": {
"CF_ACCOUNT_HASH": "your_account_hash"
}
// For production, use secrets instead:
// wrangler secret put CF_ACCOUNT_ID
// wrangler secret put CF_API_TOKEN
}
examples/private-images/.env.example
# Cloudflare Images Configuration
# Copy this file to .env and fill in your credentials
CF_ACCOUNT_ID=your_account_id_here
CF_API_TOKEN=your_api_token_here
CF_ACCOUNT_HASH=your_account_hash_here
CF_IMAGES_SIGNING_KEY=your_signing_key_here
# Get your credentials:
# - Account ID: Dashboard → Workers & Pages → Account ID (right sidebar)
# - API Token: Dashboard → My Profile → API Tokens → Create Token → "Edit Cloudflare Images"
# - Account Hash: Dashboard → Images → Serving Images → Account Hash
# - Signing Key: Dashboard → Images → Signing Keys → Create Key
# Or generate with: openssl rand -hex 32
examples/private-images/package.json
{
"name": "cloudflare-images-private-images",
"version": "1.0.0",
"description": "Complete implementation of signed URLs for private image access control using Cloudflare Images",
"main": "src/index.ts",
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"tail": "wrangler tail"
},
"keywords": [
"cloudflare",
"images",
"workers",
"signed-urls",
"private-images",
"access-control",
"hmac",
"security"
],
"author": "",
"license": "MIT",
"dependencies": {
"hono": "^4.12.12",
"@tsndr/cloudflare-worker-jwt": "^2.5.4"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20260408.0",
"wrangler": "^4.81.0",
"typescript": "^5.9.3"
}
}
examples/private-images/public/index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cloudflare Images - Private Images with Signed URLs</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.container {
background: white;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
padding: 40px;
max-width: 700px;
width: 100%;
}
h1 {
color: #333;
margin-bottom: 10px;
font-size: 28px;
}
.subtitle {
color: #666;
margin-bottom: 30px;
font-size: 14px;
}
.security-badge {
display: inline-block;
background: #3c3;
color: white;
padding: 4px 12px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
margin-left: 10px;
}
.upload-form {
margin-bottom: 30px;
}
.file-input-wrapper {
position: relative;
margin-bottom: 20px;
}
input[type="file"] {
width: 100%;
padding: 15px;
border: 2px dashed #ddd;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
transition: border-color 0.3s;
}
input[type="file"]:hover {
border-color: #667eea;
}
.expiry-selector {
margin-bottom: 20px;
}
.expiry-selector label {
display: block;
color: #333;
font-weight: 600;
margin-bottom: 8px;
font-size: 14px;
}
.expiry-selector select {
width: 100%;
padding: 12px;
border: 2px solid #ddd;
border-radius: 8px;
font-size: 14px;
cursor: pointer;
transition: border-color 0.3s;
}
.expiry-selector select:focus {
outline: none;
border-color: #667eea;
}
.upload-button {
width: 100%;
padding: 15px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s, opacity 0.3s;
}
.upload-button:hover:not(:disabled) {
transform: translateY(-2px);
}
.upload-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.progress-container {
display: none;
margin-bottom: 20px;
}
.progress-bar {
width: 100%;
height: 8px;
background: #f0f0f0;
border-radius: 4px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
width: 0%;
transition: width 0.3s;
}
.progress-text {
margin-top: 8px;
color: #666;
font-size: 14px;
text-align: center;
}
.error {
background: #fee;
color: #c33;
padding: 15px;
border-radius: 8px;
margin-bottom: 20px;
border-left: 4px solid #c33;
display: none;
}
.success {
background: #efe;
color: #3c3;
padding: 15px;
border-radius: 8px;
margin-bottom: 20px;
border-left: 4px solid #3c3;
display: none;
}
.uploaded-image-container {
display: none;
}
.uploaded-image-container h2 {
color: #333;
margin-bottom: 15px;
font-size: 20px;
}
.expiry-info {
background: #fff3cd;
border: 1px solid #ffc107;
color: #856404;
padding: 12px;
border-radius: 8px;
margin-bottom: 15px;
font-size: 14px;
}
.expiry-info strong {
color: #333;
}
.countdown {
font-weight: 600;
color: #c33;
}
.uploaded-image {
max-width: 100%;
border-radius: 8px;
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
margin-bottom: 15px;
}
.image-details {
margin-bottom: 15px;
padding: 15px;
background: #f8f8f8;
border-radius: 8px;
font-size: 13px;
color: #666;
}
.image-details div {
margin: 5px 0;
}
.image-details strong {
color: #333;
}
.refresh-button {
width: 100%;
padding: 12px;
background: #ffc107;
color: #333;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s;
}
.refresh-button:hover {
transform: translateY(-2px);
}
.info-section {
margin-top: 30px;
padding: 20px;
background: #f0f7ff;
border-radius: 8px;
border-left: 4px solid #667eea;
}
.info-section h3 {
color: #333;
margin-bottom: 10px;
font-size: 16px;
}
.info-section p {
color: #666;
font-size: 14px;
line-height: 1.6;
}
.info-section ul {
margin-top: 10px;
padding-left: 20px;
}
.info-section li {
color: #666;
font-size: 14px;
margin: 5px 0;
}
</style>
</head>
<body>
<div class="container">
<h1>🔒 Private Images <span class="security-badge">Secure</span></h1>
<p class="subtitle">Signed URLs with Time-Based Expiry</p>
<div class="error" id="error"></div>
<div class="success" id="success"></div>
<form class="upload-form" id="upload-form">
<div class="file-input-wrapper">
<input
type="file"
id="file-input"
accept="image/jpeg,image/png,image/webp,image/gif"
required
/>
</div>
<div class="expiry-selector">
<label for="expiry-select">URL Expiry Time:</label>
<select id="expiry-select">
<option value="300">5 minutes (Highly Sensitive)</option>
<option value="900">15 minutes (Sensitive)</option>
<option value="3600" selected>1 hour (Default)</option>
<option value="14400">4 hours (Extended)</option>
<option value="86400">24 hours (Public Sharing)</option>
</select>
</div>
<div class="progress-container" id="progress-container">
<div class="progress-bar">
<div class="progress-fill" id="progress-fill"></div>
</div>
<div class="progress-text" id="progress-text">Uploading... 0%</div>
</div>
<button type="submit" class="upload-button" id="upload-button">
Upload Private Image
</button>
</form>
<div class="uploaded-image-container" id="uploaded-image-container">
<h2>✅ Private Image Uploaded!</h2>
<div class="expiry-info" id="expiry-info">
<strong>URL Expires:</strong> <span id="expiry-time"></span><br>
<strong>Time Remaining:</strong> <span class="countdown" id="countdown"></span>
</div>
<img class="uploaded-image" id="uploaded-image" alt="Private image">
<div class="image-details" id="image-details"></div>
<button class="refresh-button" id="refresh-button">
🔄 Refresh Signed URL (Extend Access)
</button>
</div>
<div class="info-section">
<h3>🛡️ How Signed URLs Work</h3>
<p>
This example demonstrates <strong>signed URLs</strong> for private image access control.
The image can only be accessed with a cryptographically signed URL that expires after a set time.
</p>
<ul>
<li><strong>HMAC-SHA256 Signatures:</strong> URLs are signed using your secret key</li>
<li><strong>Time-Based Expiry:</strong> URLs automatically expire after the selected duration</li>
<li><strong>Access Control:</strong> No access without a valid signature</li>
<li><strong>Perfect For:</strong> User uploads, premium content, temporary sharing, HIPAA/GDPR compliance</li>
</ul>
</div>
</div>
<script>
// Configuration
const API_URL = 'http://localhost:8787'; // Change to your Worker URL in production
// DOM elements
const form = document.getElementById('upload-form');
const fileInput = document.getElementById('file-input');
const expirySelect = document.getElementById('expiry-select');
const uploadButton = document.getElementById('upload-button');
const progressContainer = document.getElementById('progress-container');
const progressFill = document.getElementById('progress-fill');
const progressText = document.getElementById('progress-text');
const errorDiv = document.getElementById('error');
const successDiv = document.getElementById('success');
const uploadedImageContainer = document.getElementById('uploaded-image-container');
const uploadedImage = document.getElementById('uploaded-image');
const imageDetails = document.getElementById('image-details');
const expiryInfo = document.getElementById('expiry-info');
const expiryTime = document.getElementById('expiry-time');
const countdown = document.getElementById('countdown');
const refreshButton = document.getElementById('refresh-button');
let currentImageId = null;
let countdownInterval = null;
// Form submit handler
form.addEventListener('submit', async (e) => {
e.preventDefault();
await uploadPrivateImage();
});
// Refresh button handler
refreshButton.addEventListener('click', async () => {
if (currentImageId) {
await generateSignedUrl(currentImageId);
}
});
async function uploadPrivateImage() {
// Get selected file
const file = fileInput.files[0];
if (!file) {
showError('Please select a file');
return;
}
// Validate file size (max 10MB)
const maxSize = 10 * 1024 * 1024;
if (file.size > maxSize) {
showError(`File too large (${(file.size / 1024 / 1024).toFixed(1)}MB). Maximum size is 10MB.`);
return;
}
// Validate file type
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
if (!allowedTypes.includes(file.type)) {
showError('Invalid file type. Please upload JPEG, PNG, WebP, or GIF.');
return;
}
try {
// Reset UI
hideError();
hideSuccess();
uploadedImageContainer.style.display = 'none';
// Disable form
uploadButton.disabled = true;
fileInput.disabled = true;
expirySelect.disabled = true;
// Show progress
showProgress(10);
// Step 1: Upload private image
console.log('Uploading private image...');
const uploadFormData = new FormData();
uploadFormData.append('file', file);
const uploadResponse = await fetch(`${API_URL}/api/upload-private`, {
method: 'POST',
body: uploadFormData
});
if (!uploadResponse.ok) {
throw new Error(`Upload failed: ${uploadResponse.statusText}`);
}
const uploadResult = await uploadResponse.json();
console.log('Private image uploaded. Image ID:', uploadResult.imageId);
currentImageId = uploadResult.imageId;
showProgress(50);
// Step 2: Generate signed URL
await generateSignedUrl(uploadResult.imageId);
showProgress(100);
showSuccess('Private image uploaded successfully!');
// Reset form
form.reset();
} catch (error) {
console.error('Upload error:', error);
showError(error.message);
} finally {
// Re-enable form
uploadButton.disabled = false;
fileInput.disabled = false;
expirySelect.disabled = false;
hideProgress();
}
}
async function generateSignedUrl(imageId) {
try {
const expirySeconds = parseInt(expirySelect.value);
console.log('Generating signed URL...', { imageId, expirySeconds });
const signResponse = await fetch(`${API_URL}/api/sign-url`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
imageId,
variant: 'public',
expirySeconds
})
});
if (!signResponse.ok) {
throw new Error(`Signing failed: ${signResponse.statusText}`);
}
const signResult = await signResponse.json();
console.log('Signed URL generated:', signResult);
// Display image
uploadedImage.src = signResult.signedUrl;
// Show expiry info
const expiresAt = new Date(signResult.expiresAt);
expiryTime.textContent = expiresAt.toLocaleString();
// Start countdown
startCountdown(expiresAt);
// Show image details
imageDetails.innerHTML = `
<div><strong>Image ID:</strong> ${imageId}</div>
<div><strong>Signed URL:</strong> <code style="font-size: 11px; word-break: break-all;">${signResult.signedUrl}</code></div>
<div><strong>Expiry:</strong> ${expirySeconds} seconds (${formatDuration(expirySeconds)})</div>
<div><strong>Security:</strong> HMAC-SHA256 signed</div>
`;
uploadedImageContainer.style.display = 'block';
} catch (error) {
console.error('Signing error:', error);
showError(error.message);
}
}
function startCountdown(expiresAt) {
// Clear existing interval
if (countdownInterval) {
clearInterval(countdownInterval);
}
// Update countdown every second
countdownInterval = setInterval(() => {
const now = new Date();
const remaining = Math.max(0, expiresAt - now);
if (remaining === 0) {
clearInterval(countdownInterval);
countdown.textContent = 'EXPIRED';
expiryInfo.style.background = '#fee';
expiryInfo.style.borderColor = '#c33';
expiryInfo.style.color = '#c33';
} else {
countdown.textContent = formatDuration(Math.floor(remaining / 1000));
}
}, 1000);
}
function formatDuration(seconds) {
if (seconds >= 86400) {
return `${Math.floor(seconds / 86400)} days`;
} else if (seconds >= 3600) {
return `${Math.floor(seconds / 3600)} hours`;
} else if (seconds >= 60) {
return `${Math.floor(seconds / 60)} minutes`;
} else {
return `${seconds} seconds`;
}
}
// UI helper functions
function showProgress(percent) {
progressContainer.style.display = 'block';
progressFill.style.width = `${percent}%`;
progressText.textContent = `Uploading... ${percent}%`;
}
function hideProgress() {
setTimeout(() => {
progressContainer.style.display = 'none';
progressFill.style.width = '0%';
}, 500);
}
function showError(message) {
errorDiv.textContent = message;
errorDiv.style.display = 'block';
}
function hideError() {
errorDiv.style.display = 'none';
}
function showSuccess(message) {
successDiv.textContent = message;
successDiv.style.display = 'block';
}
function hideSuccess() {
successDiv.style.display = 'none';
}
</script>
</body>
</html>
examples/private-images/README.md
# Private Images Example
Complete implementation of signed URLs for private image access control using Cloudflare Images.
## Features
- ✅ HMAC-SHA256 signed URL generation
- ✅ Time-based expiry (customizable)
- ✅ Access control patterns
- ✅ Secure image delivery
- ✅ Frontend authentication flow
- ✅ Token validation
- ✅ Automatic expiry handling
## Live Demo Structure
```
private-images/
├── README.md # This file
├── package.json # Dependencies
├── wrangler.jsonc # Worker configuration
├── .env.example # Environment variables template
├── src/
│ └── index.ts # Worker with signed URL generation
└── public/
└── index.html # Gallery UI with authentication
```
## What are Signed URLs?
Signed URLs are cryptographically signed URLs that grant temporary access to private images. They prevent unauthorized access by requiring a valid signature that expires after a set time.
**Use Cases**:
- Private user content (profile photos, documents)
- Paid content (premium images, stock photos)
- Temporary sharing (time-limited access links)
- HIPAA/GDPR compliance (controlled access to sensitive images)
## Architecture
```
┌─────────────────┐
│ Browser │
│ (Gallery) │
└────────┬────────┘
│ 1. Request signed URL
▼
┌─────────────────┐
│ Worker API │
│ /api/sign-url │
└────────┬────────┘
│ 2. Generate signature
│ HMAC-SHA256(imageId + expiry)
▼
┌─────────────────┐
│ Browser │
│ Displays image │
└────────┬────────┘
│ 3. Request image with signature
▼
┌─────────────────┐
│ Cloudflare CDN │
│ Validates sig │
└─────────────────┘
```
## Implementation
### 1. Upload Private Image
Images uploaded with `requireSignedURLs: true` can only be accessed with signed URLs:
```typescript
const response = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${ACCOUNT_ID}/images/v1`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${API_TOKEN}`
},
body: formData.append('requireSignedURLs', 'true') // ← KEY
}
);
```
### 2. Generate Signed URL (Server-Side)
```typescript
import { sign } from '@tsndr/cloudflare-worker-jwt';
// Generate expiry timestamp (1 hour from now)
const expiry = Math.floor(Date.now() / 1000) + 3600;
// Create signature using HMAC-SHA256
const signature = await sign(
{ imageId, expiry },
CF_IMAGES_SIGNING_KEY
);
// Construct signed URL
const signedUrl = `https://imagedelivery.net/${ACCOUNT_HASH}/${imageId}/public?exp=${expiry}&sig=${signature}`;
```
### 3. Access Control Patterns
**Time-Based Access**:
```typescript
// 5 minutes
const expiry = Math.floor(Date.now() / 1000) + 300;
// 1 hour
const expiry = Math.floor(Date.now() / 1000) + 3600;
// 24 hours
const expiry = Math.floor(Date.now() / 1000) + 86400;
```
**User-Based Access**:
```typescript
// Check user authentication first
if (!req.user || req.user.id !== imageOwnerId) {
return c.json({ error: 'Unauthorized' }, 403);
}
// Generate signed URL only for authorized user
const signedUrl = await generateSignedUrl(imageId, expiry);
```
**Content Type Restrictions**:
```typescript
// Only allow specific variants
const allowedVariants = ['thumbnail', 'medium'];
if (!allowedVariants.includes(variant)) {
return c.json({ error: 'Forbidden variant' }, 403);
}
```
## Setup
### 1. Install Dependencies
```bash
npm install
```
### 2. Configure Environment
Copy `.env.example` to `.env`:
```bash
cp .env.example .env
```
Fill in your credentials:
```env
CF_ACCOUNT_ID=your_account_id_here
CF_API_TOKEN=your_api_token_here
CF_ACCOUNT_HASH=your_account_hash_here
CF_IMAGES_SIGNING_KEY=your_signing_key_here # Generate: openssl rand -hex 32
```
**Get your credentials**:
- **Account ID**: Dashboard → Workers & Pages → Account ID (right sidebar)
- **API Token**: Dashboard → My Profile → API Tokens → Create Token → "Edit Cloudflare Images"
- **Account Hash**: Dashboard → Images → Serving Images → Account Hash
- **Signing Key**: Dashboard → Images → Signing Keys → Create Key (or generate with `openssl rand -hex 32`)
### 3. Deploy Worker
```bash
# Development
npm run dev
# Production
npm run deploy
```
### 4. Configure Wrangler
Update `wrangler.jsonc` with your Account ID:
```jsonc
{
"account_id": "YOUR_ACCOUNT_ID" // ← Replace
}
```
### 5. Set Secrets
```bash
# Set secrets in production (more secure than .env)
npx wrangler secret put CF_ACCOUNT_ID
npx wrangler secret put CF_API_TOKEN
npx wrangler secret put CF_IMAGES_SIGNING_KEY
```
### 6. Open Gallery
```bash
# Serve frontend locally
npx serve public
# Or open directly
open public/index.html
```
## API Endpoints
### POST /api/upload-private
Upload a private image (requireSignedURLs: true).
**Request**:
```bash
curl -X POST http://localhost:8787/api/upload-private \
-F "file=@image.jpg"
```
**Response**:
```json
{
"imageId": "2cdc28f0-017a-49c4-9ed7-87056c83901",
"uploaded": true,
"requireSignedURLs": true
}
```
### POST /api/sign-url
Generate a signed URL for a private image.
**Request**:
```bash
curl -X POST http://localhost:8787/api/sign-url \
-H "Content-Type: application/json" \
-d '{
"imageId": "2cdc28f0-017a-49c4-9ed7-87056c83901",
"variant": "public",
"expirySeconds": 3600
}'
```
**Response**:
```json
{
"signedUrl": "https://imagedelivery.net/{hash}/{id}/public?exp=1234567890&sig=abc123...",
"expiresAt": "2024-01-15T12:00:00Z",
"expirySeconds": 3600
}
```
### GET /health
Health check endpoint.
**Response**:
```json
{
"status": "ok",
"service": "Cloudflare Images Private Images Example"
}
```
## Security Best Practices
### 1. Use Strong Signing Keys
```bash
# Generate cryptographically secure key
openssl rand -hex 32
# Store as Wrangler secret (not in code)
npx wrangler secret put CF_IMAGES_SIGNING_KEY
```
### 2. Short Expiry Times
```typescript
// Prefer short expiry for sensitive content
const expiry = Math.floor(Date.now() / 1000) + 300; // 5 minutes
```
### 3. Validate User Access
```typescript
// Check user owns the image before signing
const image = await db.query('SELECT owner_id FROM images WHERE id = ?', [imageId]);
if (image.owner_id !== req.user.id) {
return c.json({ error: 'Forbidden' }, 403);
}
```
### 4. Rate Limiting
```typescript
// Limit signed URL generation
import { Ratelimit } from '@upstash/ratelimit';
const ratelimit = new Ratelimit({
redis: /* your redis */,
limiter: Ratelimit.slidingWindow(10, '1m') // 10 requests per minute
});
const { success } = await ratelimit.limit(userId);
if (!success) {
return c.json({ error: 'Rate limit exceeded' }, 429);
}
```
### 5. Audit Logging
```typescript
// Log all signed URL generations
await db.insert('audit_log').values({
user_id: req.user.id,
image_id: imageId,
action: 'generate_signed_url',
expiry: expiry,
timestamp: new Date()
});
```
## Testing
### Test Signed URL Generation
```bash
# 1. Upload private image
IMAGE_ID=$(curl -X POST http://localhost:8787/api/upload-private \
-F "file=@test.jpg" | jq -r '.imageId')
# 2. Generate signed URL
SIGNED_URL=$(curl -X POST http://localhost:8787/api/sign-url \
-H "Content-Type: application/json" \
-d "{\"imageId\": \"$IMAGE_ID\", \"expirySeconds\": 300}" | jq -r '.signedUrl')
# 3. Access image
curl "$SIGNED_URL" -o output.jpg
# 4. Verify output.jpg displays correctly
open output.jpg
```
### Test Expiry
```bash
# Generate URL with 5-second expiry
SIGNED_URL=$(curl -X POST http://localhost:8787/api/sign-url \
-H "Content-Type: application/json" \
-d "{\"imageId\": \"$IMAGE_ID\", \"expirySeconds\": 5}" | jq -r '.signedUrl')
# Access immediately (should work)
curl "$SIGNED_URL" -o output1.jpg
# Wait 10 seconds
sleep 10
# Try again (should fail with 403)
curl "$SIGNED_URL" -o output2.jpg # ← Expect error
```
### Test Invalid Signature
```bash
# Try accessing without signature (should fail)
curl "https://imagedelivery.net/${ACCOUNT_HASH}/${IMAGE_ID}/public" -o output.jpg
# ← Expect: 403 Forbidden (requireSignedURLs: true)
```
## Common Use Cases
### 1. User Profile Photos
**Scenario**: Users can upload profile photos visible only to authenticated users.
```typescript
// Upload private profile photo
const uploadResponse = await uploadPrivateImage(file);
// Generate signed URL for logged-in user
const signedUrl = await generateSignedUrl(uploadResponse.imageId, 3600);
// Display in profile
<img src={signedUrl} alt="Profile" />
```
### 2. Premium Content
**Scenario**: Paid users get temporary access to premium images.
```typescript
// Check subscription
if (!user.isPremium) {
return c.json({ error: 'Subscription required' }, 402);
}
// Generate signed URL with 24-hour expiry
const signedUrl = await generateSignedUrl(imageId, 86400);
```
### 3. Temporary Sharing
**Scenario**: Generate a shareable link that expires after 1 hour.
```typescript
// Generate short-lived share link
const shareUrl = await generateSignedUrl(imageId, 3600);
// Send via email or copy to clipboard
await sendEmail(recipient, `View image: ${shareUrl}`);
```
### 4. Medical/Legal Images (HIPAA/GDPR)
**Scenario**: Highly sensitive images with strict access control.
```typescript
// Check authorization
if (!user.hasPermission('view_medical_records')) {
return c.json({ error: 'Unauthorized' }, 403);
}
// Generate very short expiry (5 minutes)
const signedUrl = await generateSignedUrl(imageId, 300);
// Log access for audit trail
await logAccess(user.id, imageId, 'medical_image_view');
```
## Performance Optimization
### 1. Cache Signed URLs
```typescript
// Cache signed URL in KV for 50% of expiry time
const cacheKey = `signed:${imageId}:${variant}`;
const cachedUrl = await env.KV.get(cacheKey);
if (cachedUrl) {
return c.json({ signedUrl: cachedUrl });
}
const signedUrl = await generateSignedUrl(imageId, expiry);
await env.KV.put(cacheKey, signedUrl, { expirationTtl: expiry / 2 });
return c.json({ signedUrl });
```
### 2. Batch Signing
```typescript
// Sign multiple images at once
const imageIds = ['id1', 'id2', 'id3'];
const signedUrls = await Promise.all(
imageIds.map(id => generateSignedUrl(id, 3600))
);
```
### 3. CDN Caching
Signed URLs are cached by Cloudflare CDN until expiry:
```
Cache-Control: public, max-age=<expiry-seconds>
```
Ensure expiry is set correctly to leverage CDN caching.
## Troubleshooting
### Issue: "Invalid signature" (403)
**Cause**: Signature verification failed.
**Solutions**:
- Verify signing key matches between upload and URL generation
- Check expiry timestamp is in the future
- Ensure URL encoding is correct (no spaces, special chars)
### Issue: Signed URL works initially, then fails
**Cause**: URL has expired.
**Solutions**:
- Increase `expirySeconds` when generating URL
- Regenerate URL before displaying to user
- Implement automatic refresh in frontend
### Issue: Cannot access image even with signature
**Cause**: Image not uploaded with `requireSignedURLs: true`.
**Solutions**:
- Re-upload image with `requireSignedURLs: true`
- Or remove signing requirement (not recommended for private content)
### Issue: Signature works in browser but not in cURL
**Cause**: URL encoding differences.
**Solutions**:
- Ensure proper URL encoding: `encodeURIComponent(signedUrl)`
- Use raw URL in cURL: `curl "$SIGNED_URL"`
## Related Examples
- **Basic Upload**: Minimal upload implementation
- **Responsive Gallery**: Public image gallery with srcset
## Related References
- **Signed URLs Guide**: `references/signed-urls-guide.md`
- **API Reference**: `references/api-reference.md`
- **Top Errors**: `references/top-errors.md`
- **Security Best Practices**: `references/api-reference.md` (Security section)
## Production Checklist
Before deploying to production:
- [ ] Store signing key in Wrangler secrets (not .env)
- [ ] Implement rate limiting on `/api/sign-url`
- [ ] Add user authentication/authorization
- [ ] Enable audit logging for signed URL generation
- [ ] Set appropriate expiry times (shorter for sensitive content)
- [ ] Test expiry behavior thoroughly
- [ ] Monitor signed URL generation rate
- [ ] Implement signed URL refresh mechanism in frontend
- [ ] Add CORS headers if accessing from different domain
- [ ] Set up error monitoring (Sentry, etc.)
## License
MIT
examples/private-images/src/index.ts
/**
* Private Images Example - Cloudflare Worker
*
* Generates signed URLs for private images with time-based expiry.
*/
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { sign } from '@tsndr/cloudflare-worker-jwt';
interface Env {
CF_ACCOUNT_ID: string;
CF_API_TOKEN: string;
CF_ACCOUNT_HASH: string;
CF_IMAGES_SIGNING_KEY: string;
}
const app = new Hono<{ Bindings: Env }>();
// CORS configuration
app.use('/*', cors({
origin: ['http://localhost:8787', 'http://localhost:3000', 'http://localhost:5173'],
allowMethods: ['GET', 'POST', 'OPTIONS'],
allowHeaders: ['Content-Type'],
credentials: true
}));
/**
* Health check endpoint
*/
app.get('/', (c) => {
return c.json({
status: 'ok',
service: 'Cloudflare Images Private Images Example',
endpoints: {
uploadPrivate: 'POST /api/upload-private',
signUrl: 'POST /api/sign-url',
health: 'GET /'
}
});
});
/**
* Upload private image
*
* POST /api/upload-private
*
* Uploads an image with requireSignedURLs: true
*
* Request (multipart/form-data):
* - file: Image file
*
* Returns:
* {
* "imageId": "2cdc28f0-017a-49c4-9ed7-87056c83901",
* "uploaded": true,
* "requireSignedURLs": true
* }
*/
app.post('/api/upload-private', async (c) => {
try {
// Verify environment variables
if (!c.env.CF_ACCOUNT_ID || !c.env.CF_API_TOKEN) {
return c.json({
error: 'Missing configuration',
message: 'CF_ACCOUNT_ID and CF_API_TOKEN must be set'
}, 500);
}
// Get file from form data
const formData = await c.req.formData();
const file = formData.get('file');
if (!file || !(file instanceof File)) {
return c.json({
error: 'Missing file',
message: 'Please provide a file in the form data'
}, 400);
}
console.log('Uploading private image:', file.name);
// Create upload form data
const uploadFormData = new FormData();
uploadFormData.append('file', file);
uploadFormData.append('requireSignedURLs', 'true'); // ← KEY: Makes image private
// Upload to Cloudflare Images
const response = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${c.env.CF_ACCOUNT_ID}/images/v1`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${c.env.CF_API_TOKEN}`
},
body: uploadFormData
}
);
const result = await response.json<any>();
if (!result.success) {
console.error('Cloudflare API error:', result.errors);
return c.json({
error: 'Upload failed',
details: result.errors
}, 500);
}
console.log('Private image uploaded successfully. Image ID:', result.result.id);
return c.json({
imageId: result.result.id,
uploaded: true,
requireSignedURLs: true
});
} catch (error) {
console.error('Error uploading private image:', error);
return c.json({
error: 'Internal server error',
message: error instanceof Error ? error.message : 'Unknown error'
}, 500);
}
});
/**
* Generate signed URL for private image
*
* POST /api/sign-url
*
* Body:
* {
* "imageId": "2cdc28f0-017a-49c4-9ed7-87056c83901",
* "variant": "public", // Optional, defaults to "public"
* "expirySeconds": 3600 // Optional, defaults to 1 hour
* }
*
* Returns:
* {
* "signedUrl": "https://imagedelivery.net/{hash}/{id}/public?exp=1234567890&sig=abc123...",
* "expiresAt": "2024-01-15T12:00:00Z",
* "expirySeconds": 3600
* }
*/
app.post('/api/sign-url', async (c) => {
try {
// Verify environment variables
if (!c.env.CF_ACCOUNT_HASH || !c.env.CF_IMAGES_SIGNING_KEY) {
return c.json({
error: 'Missing configuration',
message: 'CF_ACCOUNT_HASH and CF_IMAGES_SIGNING_KEY must be set'
}, 500);
}
// Parse request body
const body = await c.req.json<{
imageId: string;
variant?: string;
expirySeconds?: number;
}>();
const { imageId, variant = 'public', expirySeconds = 3600 } = body;
if (!imageId) {
return c.json({
error: 'Missing imageId',
message: 'Please provide imageId in request body'
}, 400);
}
console.log('Generating signed URL for:', imageId, 'variant:', variant, 'expiry:', expirySeconds);
// Generate expiry timestamp (Unix epoch)
const expiry = Math.floor(Date.now() / 1000) + expirySeconds;
// Generate signature using HMAC-SHA256
// Format: imageId + "/" + variant + expiry
const dataToSign = `${imageId}/${variant}${expiry}`;
const signature = await sign(
{ data: dataToSign },
c.env.CF_IMAGES_SIGNING_KEY,
{ algorithm: 'HS256' }
);
// Construct signed URL
const signedUrl = `https://imagedelivery.net/${c.env.CF_ACCOUNT_HASH}/${imageId}/${variant}?exp=${expiry}&sig=${signature}`;
console.log('Signed URL generated successfully');
return c.json({
signedUrl,
expiresAt: new Date(expiry * 1000).toISOString(),
expirySeconds
});
} catch (error) {
console.error('Error generating signed URL:', error);
return c.json({
error: 'Internal server error',
message: error instanceof Error ? error.message : 'Unknown error'
}, 500);
}
});
export default app;
examples/private-images/wrangler.jsonc
{
"name": "cloudflare-images-private-images",
"main": "src/index.ts",
"compatibility_date": "2025-01-15",
"account_id": "YOUR_ACCOUNT_ID",
// Environment variables (for local development)
"vars": {
"CF_ACCOUNT_HASH": "your_account_hash"
}
// For production, use secrets instead:
// wrangler secret put CF_ACCOUNT_ID
// wrangler secret put CF_API_TOKEN
// wrangler secret put CF_IMAGES_SIGNING_KEY
}
examples/responsive-gallery/README.md
# Responsive Gallery Example
Complete responsive image gallery using Cloudflare Images with srcset, lazy loading, and variant optimization.
## Features
- ✅ Responsive images with `srcset` and `sizes`
- ✅ Lazy loading for performance
- ✅ Named variants for common sizes
- ✅ Masonry grid layout
- ✅ Lightbox for full-size viewing
- ✅ WebP/AVIF automatic format negotiation
- ✅ CDN caching
## Live Demo Structure
```
responsive-gallery/
├── README.md # This file
├── index.html # Gallery UI
└── images.json # Image metadata (IDs, alt text)
```
## Implementation
### HTML Structure
```html
<div class="gallery">
<div class="gallery-item" data-image-id="abc123">
<img
src="https://imagedelivery.net/{hash}/abc123/thumbnail"
srcset="
https://imagedelivery.net/{hash}/abc123/thumbnail 300w,
https://imagedelivery.net/{hash}/abc123/medium 600w,
https://imagedelivery.net/{hash}/abc123/large 1200w
"
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
alt="Product photo"
loading="lazy"
decoding="async"
/>
</div>
</div>
```
### Variants Configuration
Create these variants using `/generate-variant` command:
```json
{
"thumbnail": { "width": 300, "height": 300, "fit": "cover", "quality": 80 },
"medium": { "width": 600, "height": 600, "fit": "scale-down", "quality": 85 },
"large": { "width": 1200, "height": 1200, "fit": "scale-down", "quality": 90 }
}
```
### Responsive Behavior
- **Mobile (<640px)**: Loads `thumbnail` variant (300px)
- **Tablet (640-1024px)**: Loads `medium` variant (600px)
- **Desktop (>1024px)**: Loads `large` variant (1200px)
- **Retina displays**: Automatically serves higher resolution
### Performance Optimizations
1. **Lazy Loading**: Images load as user scrolls
2. **Decoding Async**: Non-blocking image decode
3. **Format Auto**: WebP/AVIF served automatically (25-50% smaller)
4. **CDN Caching**: Cached globally at edge locations
5. **Named Variants**: Pre-defined sizes for consistency
### Lighthouse Scores
Expected scores with optimizations:
- **Performance**: 95-100
- **Largest Contentful Paint (LCP)**: <2.5s
- **Cumulative Layout Shift (CLS)**: <0.1
- **Total Blocking Time (TBT)**: <200ms
## Setup
### 1. Create Variants
```bash
# Use the generate-variant command for each size
/generate-variant
# Or via API:
curl -X POST \
"https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/images/v1/variants" \
-H "Authorization: Bearer ${CF_API_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"id": "thumbnail", "options": {"width": 300, "height": 300, "fit": "cover"}}'
```
### 2. Configure Image Data
Edit `images.json` with your image IDs:
```json
[
{
"id": "2cdc28f0-017a-49c4-9ed7-87056c83901",
"alt": "Product 1",
"title": "Modern Chair"
},
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"alt": "Product 2",
"title": "Wooden Table"
}
]
```
### 3. Open Gallery
```bash
# Serve locally
npx serve .
# Or open directly
open index.html
```
## Advanced Features
### Lightbox Implementation
```javascript
// Click image to view full size
item.addEventListener('click', () => {
const lightbox = document.createElement('div');
lightbox.className = 'lightbox';
lightbox.innerHTML = `
<img src="https://imagedelivery.net/${ACCOUNT_HASH}/${imageId}/large?format=auto" />
`;
document.body.appendChild(lightbox);
});
```
### Infinite Scroll
```javascript
// Load more images on scroll
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
loadMoreImages();
}
});
observer.observe(document.querySelector('.load-more-trigger'));
```
### Search and Filter
```javascript
// Filter gallery by search term
function filterGallery(searchTerm) {
const items = document.querySelectorAll('.gallery-item');
items.forEach(item => {
const alt = item.querySelector('img').alt.toLowerCase();
item.style.display = alt.includes(searchTerm.toLowerCase()) ? 'block' : 'none';
});
}
```
## Related Examples
- **Basic Upload**: Minimal upload implementation
- **Private Images**: Signed URLs for access control
## Related References
- **Responsive Images**: `references/responsive-images-patterns.md`
- **Variants Guide**: `references/variants-guide.md`
- **Format Optimization**: `references/format-optimization.md`
references/api-reference.md
# Cloudflare Images API Reference
Complete API endpoints for Cloudflare Images.
**Base URL**: `https://api.cloudflare.com/client/v4/accounts/{account_id}`
**Batch API**: `https://batch.imagedelivery.net`
---
## Authentication
All requests require an API token with **Cloudflare Images: Edit** permission.
```bash
Authorization: Bearer <API_TOKEN>
```
Get API token: Dashboard → My Profile → API Tokens → Create Token
---
## Upload Endpoints
### Upload Image (File)
`POST /accounts/{account_id}/images/v1`
Upload an image file.
**Headers**:
- `Authorization: Bearer <API_TOKEN>`
- `Content-Type: multipart/form-data`
**Form Fields**:
- `file` (required): Image file
- `id` (optional): Custom ID (auto-generated if not provided)
- `requireSignedURLs` (optional): `true` for private images
- `metadata` (optional): JSON object (max 1024 bytes)
**Example**:
```bash
curl --request POST \
https://api.cloudflare.com/client/v4/accounts/{account_id}/images/v1 \
--header "Authorization: Bearer <API_TOKEN>" \
--form 'file=@./image.jpg' \
--form 'requireSignedURLs=false' \
--form 'metadata={"key":"value"}'
```
**Response**:
```json
{
"success": true,
"result": {
"id": "2cdc28f0-017a-49c4-9ed7-87056c83901",
"filename": "image.jpg",
"uploaded": "2022-01-31T16:39:28.458Z",
"requireSignedURLs": false,
"variants": [
"https://imagedelivery.net/Vi7wi5KSItxGFsWRG2Us6Q/2cdc28f0.../public"
]
}
}
```
---
### Upload via URL
`POST /accounts/{account_id}/images/v1`
Ingest image from external URL.
**Form Fields**:
- `url` (required): Image URL to ingest
- `id` (optional): Custom ID
- `requireSignedURLs` (optional): `true` for private images
- `metadata` (optional): JSON object
**Example**:
```bash
curl --request POST \
https://api.cloudflare.com/client/v4/accounts/{account_id}/images/v1 \
--header "Authorization: Bearer <API_TOKEN>" \
--form 'url=https://example.com/image.jpg' \
--form 'metadata={"source":"external"}'
```
**Note**: Cannot use both `file` and `url` in same request.
---
### Direct Creator Upload
`POST /accounts/{account_id}/images/v2/direct_upload`
Generate one-time upload URL for user uploads.
**Headers**:
- `Authorization: Bearer <API_TOKEN>`
- `Content-Type: application/json`
**Body**:
```json
{
"requireSignedURLs": false,
"metadata": {"userId": "12345"},
"expiry": "2025-10-26T18:00:00Z",
"id": "custom-id"
}
```
**Fields**:
- `requireSignedURLs` (optional): `true` for private images
- `metadata` (optional): JSON object
- `expiry` (optional): ISO 8601 timestamp (default: 30min, max: 6hr)
- `id` (optional): Custom ID (cannot use with `requireSignedURLs=true`)
**Response**:
```json
{
"success": true,
"result": {
"id": "2cdc28f0-017a-49c4-9ed7-87056c83901",
"uploadURL": "https://upload.imagedelivery.net/..."
}
}
```
**Frontend Upload**:
```javascript
const formData = new FormData();
formData.append('file', fileInput.files[0]); // MUST be named 'file'
await fetch(uploadURL, {
method: 'POST',
body: formData // NO Content-Type header
});
```
---
## Image Management
### List Images
`GET /accounts/{account_id}/images/v2`
List all images (paginated).
**Query Params**:
- `page` (optional): Page number (default: 1)
- `per_page` (optional): Results per page (default: 100, max: 100)
**Example**:
```bash
curl "https://api.cloudflare.com/client/v4/accounts/{account_id}/images/v2?page=1&per_page=50" \
--header "Authorization: Bearer <API_TOKEN>"
```
**Response**:
```json
{
"success": true,
"result": {
"images": [
{
"id": "2cdc28f0-017a-49c4-9ed7-87056c83901",
"filename": "image.jpg",
"uploaded": "2022-01-31T16:39:28.458Z",
"requireSignedURLs": false,
"variants": ["https://imagedelivery.net/.../public"]
}
]
}
}
```
---
### Get Image Details
`GET /accounts/{account_id}/images/v1/{image_id}`
Get details of specific image.
**Example**:
```bash
curl "https://api.cloudflare.com/client/v4/accounts/{account_id}/images/v1/{image_id}" \
--header "Authorization: Bearer <API_TOKEN>"
```
**Response**:
```json
{
"success": true,
"result": {
"id": "2cdc28f0-017a-49c4-9ed7-87056c83901",
"filename": "image.jpg",
"uploaded": "2022-01-31T16:39:28.458Z",
"requireSignedURLs": false,
"draft": false,
"variants": ["https://imagedelivery.net/.../public"]
}
}
```
**Note**: `draft: true` means Direct Creator Upload not completed yet.
---
### Delete Image
`DELETE /accounts/{account_id}/images/v1/{image_id}`
Delete an image.
**Example**:
```bash
curl --request DELETE \
"https://api.cloudflare.com/client/v4/accounts/{account_id}/images/v1/{image_id}" \
--header "Authorization: Bearer <API_TOKEN>"
```
**Response**:
```json
{
"success": true
}
```
---
## Variants Management
### Create Variant
`POST /accounts/{account_id}/images/v1/variants`
Create a new variant.
**Body**:
```json
{
"id": "thumbnail",
"options": {
"fit": "cover",
"width": 300,
"height": 300,
"metadata": "none"
},
"neverRequireSignedURLs": false
}
```
**Options**:
- `fit`: `scale-down`, `contain`, `cover`, `crop`, `pad`
- `width`: Max width in pixels
- `height`: Max height in pixels
- `metadata`: `none`, `copyright`, `keep`
**Example**:
```bash
curl "https://api.cloudflare.com/client/v4/accounts/{account_id}/images/v1/variants" \
--header "Authorization: Bearer <API_TOKEN>" \
--header "Content-Type: application/json" \
--data '{"id":"thumbnail","options":{"fit":"cover","width":300,"height":300}}'
```
---
### List Variants
`GET /accounts/{account_id}/images/v1/variants`
List all variants.
**Example**:
```bash
curl "https://api.cloudflare.com/client/v4/accounts/{account_id}/images/v1/variants" \
--header "Authorization: Bearer <API_TOKEN>"
```
---
### Get Variant
`GET /accounts/{account_id}/images/v1/variants/{variant_id}`
Get specific variant details.
---
### Update Variant
`PATCH /accounts/{account_id}/images/v1/variants/{variant_id}`
Update existing variant.
**Body**:
```json
{
"options": {
"width": 350,
"height": 350
}
}
```
---
### Delete Variant
`DELETE /accounts/{account_id}/images/v1/variants/{variant_id}`
Delete a variant.
---
### Enable Flexible Variants
`PATCH /accounts/{account_id}/images/v1/config`
Enable or disable flexible variants (dynamic transformations).
**Body**:
```json
{
"flexible_variants": true
}
```
---
## Batch API
Same endpoints as regular API, but different host and authentication.
**Host**: `https://batch.imagedelivery.net`
**Auth**: Batch token (create in Dashboard → Images → Batch API)
**Endpoints**:
- `POST /images/v1` - Upload image
- `GET /images/v2` - List images
- `DELETE /images/v1/{image_id}` - Delete image
**Example**:
```bash
curl "https://batch.imagedelivery.net/images/v1" \
--header "Authorization: Bearer <BATCH_TOKEN>" \
--form 'file=@./image.jpg'
```
---
## Error Codes
### HTTP Status Codes
- `200 OK` - Request successful
- `400 Bad Request` - Invalid request (check error message)
- `401 Unauthorized` - Invalid or missing API token
- `403 Forbidden` - Insufficient permissions
- `404 Not Found` - Resource not found
- `413 Payload Too Large` - File too large
- `429 Too Many Requests` - Rate limit exceeded
- `500 Internal Server Error` - Cloudflare error
- `502 Bad Gateway` - Transformation error
### Cloudflare Errors
Check `errors` array in response:
```json
{
"success": false,
"errors": [
{
"code": 5400,
"message": "Error description"
}
]
}
```
Common error codes:
- `5400` - Invalid request
- `5408` - Upload timeout
- `5454` - Unsupported protocol
---
## Rate Limits
- **Standard uploads**: No published rate limits
- **Direct Creator Upload**: Limited by one-time URL expiry (default 30min, max 6hr)
- **Batch API**: Contact Cloudflare for high-volume needs
---
## Official Documentation
- **Images API**: https://developers.cloudflare.com/api/resources/images/
- **Upload Images**: https://developers.cloudflare.com/images/upload-images/
- **Direct Creator Upload**: https://developers.cloudflare.com/images/upload-images/direct-creator-upload/
- **Variants**: https://developers.cloudflare.com/images/manage-images/create-variants/
references/content-credentials.md
# Content Credentials for Cloudflare Images
Guide to image authenticity, provenance tracking, and metadata preservation with Cloudflare Images.
---
## What Are Content Credentials?
Content Credentials are metadata standards that verify:
- **Image provenance** (where image came from)
- **Edit history** (modifications made to image)
- **Creator attribution** (who created/modified image)
- **Authenticity** (whether image is original or AI-generated)
**Key Standards**:
- **C2PA** (Coalition for Content Provenance and Authenticity)
- **IPTC Photo Metadata**
- **EXIF** (Exchangeable Image File Format)
---
## Metadata Preservation in Cloudflare Images
### Default Behavior
**By default**, Cloudflare Images:
- ✅ Preserves basic EXIF orientation
- ❌ Strips most EXIF metadata (GPS, camera info, copyright)
- ❌ Removes IPTC metadata
- ❌ Removes XMP metadata
**Reason**: Privacy and file size optimization
### Preserving Metadata
**During transformation**, use `metadata=keep`:
```html
<!-- Preserve all metadata -->
<img src="/cdn-cgi/image/metadata=keep,width=800/uploads/photo.jpg" />
```
**Via Workers**:
```typescript
return fetch(imageUrl, {
cf: {
image: {
width: 800,
metadata: 'keep' // Preserve metadata
}
}
});
```
**Trade-off**:
- ✅ Preserves creator info, copyright, GPS, camera data
- ⚠️ Larger file size (+5-15%)
- ⚠️ May expose sensitive data (GPS location)
---
## EXIF Metadata
### Common EXIF Fields
```typescript
interface EXIFMetadata {
// Camera Information
Make: string; // "Canon"
Model: string; // "EOS R5"
LensModel: string; // "RF 24-70mm F2.8 L IS USM"
// Capture Settings
FNumber: number; // f/2.8
ExposureTime: string; // "1/250"
ISO: number; // 400
FocalLength: string; // "50mm"
// Date/Time
DateTimeOriginal: string; // "2025:01:15 14:23:45"
DateTime: string; // "2025:01:15 14:23:45"
// Location (GPS)
GPSLatitude: string; // "37.7749° N"
GPSLongitude: string; // "122.4194° W"
GPSAltitude: string; // "10m"
// Copyright
Copyright: string; // "© 2025 John Doe"
Artist: string; // "John Doe"
// Image Properties
Orientation: number; // 1 (normal), 3 (180°), 6 (90° CW), 8 (90° CCW)
XResolution: number; // 72 DPI
YResolution: number; // 72 DPI
}
```
### Reading EXIF Data
**Using exif-js (Browser)**:
```typescript
import EXIF from 'exif-js';
async function readEXIF(imageFile: File): Promise<any> {
return new Promise((resolve) => {
EXIF.getData(imageFile as any, function(this: any) {
const exifData = EXIF.getAllTags(this);
resolve(exifData);
});
});
}
// Usage
const file = fileInput.files[0];
const exif = await readEXIF(file);
console.log('Camera:', exif.Make, exif.Model);
console.log('Copyright:', exif.Copyright);
```
**Using exifreader (Node.js)**:
```typescript
import ExifReader from 'exifreader';
import { readFile } from 'fs/promises';
const buffer = await readFile('photo.jpg');
const tags = ExifReader.load(buffer);
console.log('Copyright:', tags.Copyright?.description);
console.log('GPS:', tags.GPSLatitude?.description, tags.GPSLongitude?.description);
```
---
## IPTC Metadata
### IPTC Photo Metadata Standard
```typescript
interface IPTCMetadata {
// Creator Information
Creator: string[]; // Photographer name(s)
CreatorJobTitle: string; // "Photographer"
CreatorAddress: string;
CreatorCity: string;
CreatorCountry: string;
// Copyright
CopyrightNotice: string; // "© 2025 John Doe"
RightsUsageTerms: string; // "All rights reserved"
WebStatement: string; // URL to copyright info
// Image Description
Caption: string; // Image description
Headline: string; // Brief title
Keywords: string[]; // ["landscape", "sunset", "beach"]
// Usage Rights
CreditLine: string; // "Photo by John Doe"
Source: string; // "Example Photography"
// Administrative
DateCreated: string; // "2025-01-15"
IntellectualGenre: string; // "Documentary Photography"
}
```
---
## C2PA Content Credentials
### What is C2PA?
The **Coalition for Content Provenance and Authenticity** provides standards for:
- Verifying image authenticity
- Tracking edits and modifications
- Attributing creators
- Detecting AI-generated content
**Supported by**:
- Adobe, Microsoft, Google, BBC, Sony, Nikon, Canon
### How C2PA Works
1. **Content Binding**:
- Digital signature embedded in image
- Links to external manifest (JSON)
2. **Manifest Contains**:
- Creator information
- Edit history
- Assertions (original vs AI-generated)
- Ingredients (source images)
3. **Verification**:
- Check signature validity
- Verify no tampering occurred
- Display provenance to users
### Implementing C2PA
**Note**: Cloudflare Images doesn't natively support C2PA manifest creation. Implement before upload:
```typescript
// Pseudo-code (requires C2PA library)
import { createC2PAManifest } from 'c2pa';
async function addContentCredentials(
imageBuffer: ArrayBuffer,
metadata: {
creator: string;
title: string;
createdDate: string;
assertions: string[];
}
): Promise<ArrayBuffer> {
const manifest = createC2PAManifest({
claim: {
creator: metadata.creator,
title: metadata.title,
dateCreated: metadata.createdDate,
assertions: metadata.assertions
}
});
const signedImage = await manifest.embed(imageBuffer);
return signedImage;
}
// Upload to Cloudflare Images
const credentialedImage = await addContentCredentials(imageBuffer, {
creator: 'John Doe',
title: 'Sunset at Beach',
createdDate: '2025-01-15',
assertions: ['human-created', 'no-ai-generation']
});
// Upload signedImage to Cloudflare...
```
---
## Preserving Copyright Information
### Add Copyright to Image
```typescript
import ExifWriter from 'exif-js';
async function addCopyright(
imageBuffer: ArrayBuffer,
copyrightText: string
): Promise<ArrayBuffer> {
const exif = {
Copyright: copyrightText,
Artist: 'Your Name',
ImageDescription: 'Image description'
};
// Write EXIF data
const modifiedBuffer = await ExifWriter.insert(exif, imageBuffer);
return modifiedBuffer;
}
// Usage
const copyrighted = await addCopyright(imageBuffer, '© 2025 Your Company. All Rights Reserved.');
// Upload to Cloudflare Images with metadata=keep
```
**Store in Database** (Alternative):
```typescript
// Store copyright info separately
await db.images.create({
data: {
cloudflareId: imageId,
copyright: '© 2025 Your Company',
creator: 'John Doe',
license: 'All Rights Reserved',
createdAt: new Date()
}
});
// Display copyright from database (not embedded in image)
```
---
## AI-Generated Content Attribution
### Marking AI-Generated Images
**Metadata Approach**:
```typescript
await db.images.create({
data: {
cloudflareId: imageId,
isAIGenerated: true,
aiModel: 'DALL-E 3',
prompt: 'A sunset over mountains',
generatedAt: new Date()
}
});
```
**Visible Watermark**:
```html
<div class="relative">
<img src="https://images.yourdomain.com/ai-generated-id/public" alt="AI Generated" />
<div class="absolute top-2 left-2 bg-purple-600 text-white px-2 py-1 rounded text-xs">
🤖 AI Generated
</div>
</div>
```
**EXIF Custom Field**:
```typescript
const exif = {
ImageDescription: 'AI Generated by DALL-E 3',
Copyright: '© 2025 Your Company (AI Generated)',
UserComment: 'Created with artificial intelligence'
};
```
---
## Privacy Considerations
### GPS Data Removal
**Why remove GPS data**:
- Privacy protection (home address, location tracking)
- Security concerns (sensitive locations)
**Cloudflare Images removes GPS by default** ✅
**Manual removal** (if needed before upload):
```typescript
import piexif from 'piexifjs';
function removeGPS(imageDataURL: string): string {
const exif = piexif.load(imageDataURL);
// Remove GPS data
delete exif['GPS'];
const exifBytes = piexif.dump(exif);
const newDataURL = piexif.insert(exifBytes, imageDataURL);
return newDataURL;
}
```
### Sensitive Metadata
**Metadata that may expose privacy**:
- GPS coordinates (exact location)
- Camera serial number (device tracking)
- Timestamps (when photo taken)
- Wi-Fi network names (in some camera models)
**Best practice**: Strip metadata for user-uploaded images unless specifically needed.
---
## Displaying Provenance to Users
### Photo Credit Display
```tsx
interface ImageWithCreditProps {
imageId: string;
creator: string;
copyright: string;
license: string;
}
export function ImageWithCredit({
imageId,
creator,
copyright,
license
}: ImageWithCreditProps) {
return (
<figure>
<img
src={`https://images.yourdomain.com/${imageId}/public`}
alt={`Photo by ${creator}`}
/>
<figcaption className="text-sm text-gray-600 mt-2">
<div>Photo by {creator}</div>
<div>{copyright}</div>
<div>License: {license}</div>
</figcaption>
</figure>
);
}
```
---
## Legal Compliance
### DMCA Compliance
If hosting user-uploaded images:
1. **Copyright Notice**:
```
© [Year] [Owner]. All rights reserved.
Unauthorized use prohibited.
```
2. **DMCA Agent**:
- Designate agent for copyright complaints
- Provide contact information
- Register with US Copyright Office
3. **Takedown Process**:
```typescript
async function processDMCATakedown(imageId: string) {
// Remove from Cloudflare Images
await fetch(
`https://api.cloudflare.com/client/v4/accounts/${accountId}/images/v1/${imageId}`,
{
method: 'DELETE',
headers: { 'Authorization': `Bearer ${apiToken}` }
}
);
// Mark in database
await db.images.update({
where: { cloudflareId: imageId },
data: { status: 'dmca_removed', removedAt: new Date() }
});
}
```
---
## Best Practices
### 1. Strip Metadata for Privacy
```typescript
// For user uploads, remove GPS and sensitive data
await uploadImage(file, {
stripMetadata: true // Default behavior in Cloudflare Images
});
```
### 2. Preserve Copyright for Attribution
```typescript
// For professional photography, keep copyright
await uploadImage(file, {
preserveMetadata: true,
metadata: {
copyright: '© 2025 Photographer Name',
creator: 'Photographer Name'
}
});
```
### 3. Store Provenance Separately
```typescript
// Store in database for flexibility
await db.images.create({
data: {
cloudflareId,
source: 'user_upload',
originalFilename: file.name,
uploadedBy: userId,
copyright,
license,
provenance: {
creator,
dateCreated,
editHistory: []
}
}
});
```
---
## Tools and Libraries
**EXIF Reading/Writing**:
- **exif-js**: https://github.com/exif-js/exif-js (Browser)
- **exifreader**: https://github.com/mattiasw/ExifReader (Node.js)
- **piexif**: https://github.com/hMatoba/piexifjs (Browser)
**C2PA Libraries**:
- **Adobe C2PA**: https://github.com/contentauth/c2pa-js (JavaScript)
- **C2PA Rust**: https://github.com/contentauth/c2pa-rs (Rust/WASM)
**Image Metadata Tools**:
- **ExifTool**: https://exiftool.org/ (CLI)
- **ImageMagick**: https://imagemagick.org/ (CLI)
---
## Related References
- **Upload API**: See `references/api-reference.md`
- **Transformations**: See `references/transformation-options.md`
- **Overlays/Watermarks**: See `references/overlays-watermarks.md`
---
## Official Documentation
- **Cloudflare Images**: https://developers.cloudflare.com/images/
- **C2PA**: https://c2pa.org/
- **IPTC**: https://www.iptc.org/standards/photo-metadata/
references/custom-domains.md
# Custom Domains for Cloudflare Images
Complete guide to serving Cloudflare Images from your own custom domain instead of the default `imagedelivery.net`.
---
## Why Use Custom Domains?
**Default URL**:
```
https://imagedelivery.net/<ACCOUNT_HASH>/<IMAGE_ID>/public
```
**Custom Domain URL**:
```
https://images.yourdomain.com/<IMAGE_ID>/public
```
**Benefits**:
- **Branding**: Use your own domain for professional appearance
- **SEO**: Keep image URLs on your domain
- **Control**: Manage DNS and caching policies
- **Privacy**: Hide Cloudflare account hash
- **CDN**: Leverage Cloudflare's global network with your domain
---
## Prerequisites
1. **Active Cloudflare Account** with Images enabled
2. **Domain on Cloudflare** (DNS managed by Cloudflare)
3. **SSL/TLS Certificate** (automatic with Cloudflare)
4. **Images Subscription** (custom domains available on paid plans)
---
## Setup Guide
### Step 1: Add Subdomain to Cloudflare
1. **Navigate to DNS**:
- Dashboard → Your Domain → DNS → Records
2. **Add CNAME Record**:
- Type: `CNAME`
- Name: `images` (or your preferred subdomain)
- Target: `imagedelivery.net`
- Proxy status: **Proxied** (orange cloud)
Example:
```
images.yourdomain.com → CNAME → imagedelivery.net (Proxied)
```
### Step 2: Configure Custom Domain in Images
1. **Navigate to Images Settings**:
- Dashboard → Images → Custom Domains
2. **Add Custom Domain**:
- Enter: `images.yourdomain.com`
- Click "Add Domain"
3. **Verify Setup**:
- Cloudflare validates DNS configuration
- SSL certificate provisioned automatically
- Status changes to "Active"
### Step 3: Update Image URLs
**Before** (default):
```html
<img src="https://imagedelivery.net/Vi7wi5KSItxGFsWRG2Us6Q/2cdc28f0.../public" />
```
**After** (custom domain):
```html
<img src="https://images.yourdomain.com/2cdc28f0.../public" />
```
**Note**: Account hash is removed from URL when using custom domains.
---
## Configuration Options
### SSL/TLS Settings
**Automatic SSL** (Recommended):
- Cloudflare provisions Universal SSL automatically
- HTTPS enabled by default
- Certificate auto-renews
**Custom SSL** (Advanced):
- Upload custom certificate in SSL/TLS settings
- For specific compliance requirements
### Caching Configuration
**Browser Cache TTL**:
Dashboard → Images → Settings → Browser TTL
```
Default: 4 hours
Range: 30 minutes to 1 year
```
**Edge Cache TTL**:
Automatically optimized by Cloudflare (not configurable)
---
## Implementation Examples
### Static HTML
```html
<!-- Responsive images with custom domain -->
<img
srcset="
https://images.yourdomain.com/photo-id/width=400 400w,
https://images.yourdomain.com/photo-id/width=800 800w,
https://images.yourdomain.com/photo-id/width=1200 1200w
"
sizes="(max-width: 600px) 400px, (max-width: 1000px) 800px, 1200px"
src="https://images.yourdomain.com/photo-id/width=800"
alt="Product photo"
/>
```
### React/Next.js
```tsx
// components/CloudflareImage.tsx
interface CloudflareImageProps {
imageId: string;
variant?: string;
width?: number;
quality?: number;
format?: 'auto' | 'webp' | 'avif' | 'jpeg' | 'png';
alt: string;
}
export function CloudflareImage({
imageId,
variant = 'public',
width,
quality = 85,
format = 'auto',
alt
}: CloudflareImageProps) {
const CUSTOM_DOMAIN = 'https://images.yourdomain.com';
// Build transformation parameters
const params = new URLSearchParams();
if (width) params.set('width', width.toString());
if (quality) params.set('quality', quality.toString());
if (format) params.set('format', format);
const transformations = params.toString() ? `?${params}` : '';
const imageUrl = `${CUSTOM_DOMAIN}/${imageId}/${variant}${transformations}`;
return <img src={imageUrl} alt={alt} loading="lazy" />;
}
// Usage
<CloudflareImage
imageId="2cdc28f0-017a-49c4-9ed7-87056c83901"
variant="thumbnail"
width={400}
quality={90}
format="auto"
alt="Product thumbnail"
/>
```
### Vue/Nuxt
```vue
<!-- components/CloudflareImage.vue -->
<template>
<img :src="imageUrl" :alt="alt" loading="lazy" />
</template>
<script setup lang="ts">
import { computed } from 'vue';
interface Props {
imageId: string;
variant?: string;
width?: number;
quality?: number;
format?: 'auto' | 'webp' | 'avif';
alt: string;
}
const props = withDefaults(defineProps<Props>(), {
variant: 'public',
quality: 85,
format: 'auto'
});
const CUSTOM_DOMAIN = 'https://images.yourdomain.com';
const imageUrl = computed(() => {
const params = new URLSearchParams();
if (props.width) params.set('width', props.width.toString());
params.set('quality', props.quality.toString());
params.set('format', props.format);
const transformations = `?${params}`;
return `${CUSTOM_DOMAIN}/${props.imageId}/${props.variant}${transformations}`;
});
</script>
<!-- Usage -->
<CloudflareImage
image-id="2cdc28f0-017a-49c4-9ed7-87056c83901"
variant="hero"
:width="1920"
:quality="90"
alt="Hero image"
/>
```
---
## Advanced Configurations
### Multiple Custom Domains
You can configure multiple subdomains for different purposes:
```
images.yourdomain.com → Product images
assets.yourdomain.com → Static assets
media.yourdomain.com → User uploads
thumbnails.yourdomain.com → Thumbnail variants
```
**Setup**:
1. Add CNAME for each subdomain
2. Configure each in Images dashboard
3. Use appropriate domain per use case
### CDN Integration
Custom domains automatically benefit from Cloudflare's global CDN:
- **200+ Data Centers** worldwide
- **Automatic caching** at edge locations
- **DDoS protection** included
- **Analytics** available in dashboard
### Cache Purging
**Purge specific image**:
```bash
curl --request POST \
https://api.cloudflare.com/client/v4/zones/{zone_id}/purge_cache \
--header "Authorization: Bearer <API_TOKEN>" \
--header "Content-Type: application/json" \
--data '{
"files": [
"https://images.yourdomain.com/image-id/public"
]
}'
```
**Purge all images**:
```bash
curl --request POST \
https://api.cloudflare.com/client/v4/zones/{zone_id}/purge_cache \
--header "Authorization: Bearer <API_TOKEN>" \
--header "Content-Type: application/json" \
--data '{"purge_everything": true}'
```
---
## DNS Propagation
After adding CNAME record:
- **Propagation time**: Usually 1-5 minutes
- **Global propagation**: Up to 24 hours (rare)
- **Verify**: `dig images.yourdomain.com` or `nslookup images.yourdomain.com`
**Check DNS**:
```bash
# Should show CNAME to imagedelivery.net
dig images.yourdomain.com CNAME
# Expected output:
# images.yourdomain.com. 300 IN CNAME imagedelivery.net.
```
---
## Migrating from imagedelivery.net
### Strategy 1: Gradual Migration
```typescript
// Environment variable controls domain
const IMAGE_DOMAIN = process.env.USE_CUSTOM_DOMAIN
? 'https://images.yourdomain.com'
: 'https://imagedelivery.net/Vi7wi5KSItxGFsWRG2Us6Q';
function getImageUrl(imageId: string, variant: string) {
if (process.env.USE_CUSTOM_DOMAIN) {
return `${IMAGE_DOMAIN}/${imageId}/${variant}`;
} else {
return `${IMAGE_DOMAIN}/${imageId}/${variant}`;
}
}
```
### Strategy 2: URL Rewriting
Use Cloudflare Workers to rewrite old URLs:
```typescript
export default {
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
// Rewrite old imagedelivery.net URLs
if (url.hostname === 'imagedelivery.net') {
url.hostname = 'images.yourdomain.com';
// Remove account hash from path
url.pathname = url.pathname.replace('/Vi7wi5KSItxGFsWRG2Us6Q', '');
return Response.redirect(url.toString(), 301);
}
return fetch(request);
}
};
```
---
## Troubleshooting
### Domain Not Working
**Check**:
1. DNS CNAME record exists: `dig images.yourdomain.com`
2. Proxy status is "Proxied" (orange cloud)
3. Domain is on Cloudflare (not external DNS)
4. SSL certificate is active (check SSL/TLS tab)
5. Custom domain is "Active" in Images settings
### Images Not Loading
**Common Causes**:
- DNS not propagated yet (wait 5-10 minutes)
- SSL certificate provisioning (automatic, takes ~5 minutes)
- Incorrect image ID in URL
- Firewall rules blocking requests
**Test**:
```bash
# Check if domain resolves
curl -I https://images.yourdomain.com/test-image/public
# Should return 200 OK or 404 (not DNS error)
```
### Mixed Content Warning
**Cause**: Loading images over HTTP on HTTPS page
**Solution**: Always use HTTPS for custom domain URLs:
```html
<!-- ✅ CORRECT -->
<img src="https://images.yourdomain.com/id/public" />
<!-- ❌ WRONG -->
<img src="http://images.yourdomain.com/id/public" />
```
---
## Best Practices
### 1. Use Descriptive Subdomains
```
✅ images.yourdomain.com (Clear purpose)
✅ cdn.yourdomain.com (Standard convention)
✅ assets.yourdomain.com (Common pattern)
❌ img.yourdomain.com (Too abbreviated)
❌ i.yourdomain.com (Not descriptive)
```
### 2. Configure HTTPS Only
```
# Cloudflare Page Rule (optional)
Always Use HTTPS: ON
```
### 3. Set Appropriate Cache TTL
```
Short TTL (1 hour): Frequently updated images
Medium TTL (1 day): Product images, avatars
Long TTL (1 week+): Static assets, logos
```
### 4. Monitor Performance
Dashboard → Analytics → Images:
- Requests per second
- Bandwidth usage
- Cache hit ratio
- Geographic distribution
---
## Security Considerations
### SSL/TLS
- **Always use HTTPS** (HTTP redirects automatically)
- **TLS 1.2+ required** (older versions disabled)
- **Certificate auto-renews** (no manual intervention)
### Access Control
**Private images with signed URLs still work**:
```typescript
// Generate signed URL with custom domain
const signedUrl = `https://images.yourdomain.com/${imageId}/${variant}?exp=${expiry}&sig=${signature}`;
```
**Note**: Signature generation is identical, only domain changes.
---
## Cost Implications
**Custom domains are included** in Cloudflare Images pricing:
- No additional cost for custom domain setup
- Same pricing for bandwidth and storage
- Unlimited custom domains on paid plans
**Bandwidth Pricing**:
- Same rates whether using `imagedelivery.net` or custom domain
- $1 per 100,000 delivered images
---
## Related References
- **Signed URLs**: See `references/signed-urls-guide.md`
- **Variants**: See `references/variants-guide.md`
- **Transformations**: See `references/transformation-options.md`
---
## Official Documentation
- **Custom Domains**: https://developers.cloudflare.com/images/manage-images/serve-images/serve-from-custom-domains/
- **DNS Configuration**: https://developers.cloudflare.com/dns/
- **SSL/TLS Settings**: https://developers.cloudflare.com/ssl/
references/direct-upload-complete-workflow.md
# Direct Creator Upload - Complete Workflow
Complete architecture and implementation guide for user-uploaded images.
---
## Architecture Overview
```
┌─────────┐ ┌─────────┐ ┌──────────┐
│ Browser │ │ Backend │ │Cloudflare│
│ (User) │ │ API │ │ Images │
└────┬────┘ └────┬────┘ └─────┬────┘
│ │ │
│ 1. Request upload URL │ │
├─────────────────────────────>│ │
│ POST /api/upload-url │ │
│ { userId: "123" } │ │
│ │ │
│ │ 2. Generate upload URL │
│ ├──────────────────────────────>│
│ │ POST /direct_upload │
│ │ { requireSignedURLs, metadata }│
│ │ │
│ │ 3. Return uploadURL + ID │
│ │<──────────────────────────────┤
│ │ { uploadURL, id } │
│ │ │
│ 4. Return uploadURL │ │
│<─────────────────────────────┤ │
│ { uploadURL, imageId } │ │
│ │ │
│ 5. Upload file directly │ │
├──────────────────────────────────────────────────────────────>│
│ POST uploadURL │ │
│ FormData: { file } │ │
│ │ │
│ 6. Success response │ │
│<──────────────────────────────────────────────────────────────┤
│ { success: true } │ │
│ │ │
│ 7. (Optional) Webhook │ │
│ │<──────────────────────────────┤
│ │ POST /webhook │
│ │ { imageId, status } │
│ │ │
```
---
## Implementation Steps
### Step 1: Backend - Generate Upload URL
**Endpoint**: `POST /api/upload-url`
```typescript
// backend.ts
interface Env {
IMAGES_ACCOUNT_ID: string;
IMAGES_API_TOKEN: string;
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
// Parse request
const body = await request.json<{
userId?: string;
requireSignedURLs?: boolean;
}>();
// Generate one-time upload URL
const response = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${env.IMAGES_ACCOUNT_ID}/images/v2/direct_upload`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${env.IMAGES_API_TOKEN}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
requireSignedURLs: body.requireSignedURLs ?? false,
metadata: {
userId: body.userId || 'anonymous',
uploadedAt: new Date().toISOString()
},
expiry: new Date(Date.now() + 60 * 60 * 1000).toISOString() // 1 hour
})
}
);
const result = await response.json();
return Response.json({
uploadURL: result.result?.uploadURL,
imageId: result.result?.id
});
}
};
```
### Step 2: Frontend - Request Upload URL
```javascript
// frontend.js
async function requestUploadURL() {
const response = await fetch('/api/upload-url', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
userId: getCurrentUserId(),
requireSignedURLs: false
})
});
const { uploadURL, imageId } = await response.json();
return { uploadURL, imageId };
}
```
### Step 3: Frontend - Upload to Cloudflare
```javascript
async function uploadImage(file) {
// Step 1: Get upload URL
const { uploadURL, imageId } = await requestUploadURL();
// Step 2: Upload directly to Cloudflare
const formData = new FormData();
formData.append('file', file); // MUST be named 'file'
const response = await fetch(uploadURL, {
method: 'POST',
body: formData // NO Content-Type header - browser sets multipart/form-data
});
if (!response.ok) {
throw new Error(`Upload failed: ${response.statusText}`);
}
return imageId;
}
```
---
## Frontend HTML Example
```html
<form id="upload-form">
<input type="file" id="file-input" accept="image/*" />
<button type="submit">Upload</button>
<div id="status"></div>
</form>
<script>
document.getElementById('upload-form').addEventListener('submit', async (e) => {
e.preventDefault();
const fileInput = document.getElementById('file-input');
const status = document.getElementById('status');
const file = fileInput.files[0];
if (!file) {
status.textContent = 'Please select a file';
return;
}
try {
status.textContent = 'Requesting upload URL...';
// Get upload URL from backend
const urlResponse = await fetch('/api/upload-url', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId: 'user-123' })
});
const { uploadURL, imageId } = await urlResponse.json();
status.textContent = 'Uploading...';
// Upload directly to Cloudflare
const formData = new FormData();
formData.append('file', file);
const uploadResponse = await fetch(uploadURL, {
method: 'POST',
body: formData
});
if (uploadResponse.ok) {
status.textContent = `✓ Upload successful! Image ID: ${imageId}`;
} else {
throw new Error('Upload failed');
}
} catch (error) {
status.textContent = `✗ Error: ${error.message}`;
}
});
</script>
```
---
## Webhook Integration
### Configure Webhook
1. Dashboard → Notifications → Destinations → Webhooks → Create
2. Enter webhook URL: `https://your-backend.com/webhook`
3. Notifications → All Notifications → Add → Images → Select webhook
### Handle Webhook
```typescript
// backend-webhook.ts
export default {
async fetch(request: Request): Promise<Response> {
const webhook = await request.json();
console.log('Image upload webhook:', webhook);
// {
// imageId: "abc123",
// status: "uploaded",
// metadata: { userId: "user-123" }
// }
// Update database
await db.images.create({
id: webhook.imageId,
userId: webhook.metadata.userId,
status: webhook.status,
uploadedAt: new Date()
});
return Response.json({ received: true });
}
};
```
---
## Draft vs Uploaded State
When you generate upload URL, image record is created in **draft** state.
**Check status**:
```typescript
const response = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${accountId}/images/v1/${imageId}`,
{
headers: { 'Authorization': `Bearer ${apiToken}` }
}
);
const result = await response.json();
if (result.result?.draft) {
console.log('Upload not completed yet');
} else {
console.log('Upload complete, image available');
}
```
---
## Error Handling
### Backend Errors
```typescript
try {
const response = await fetch(directUploadURL, { ... });
if (!response.ok) {
const error = await response.json();
throw new Error(`Cloudflare error: ${error.errors?.[0]?.message}`);
}
return result;
} catch (error) {
console.error('Failed to generate upload URL:', error);
return Response.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
```
### Frontend Errors
```javascript
// File size validation
const MAX_SIZE = 10 * 1024 * 1024; // 10MB
if (file.size > MAX_SIZE) {
throw new Error('File too large (max 10MB)');
}
// File type validation
if (!file.type.startsWith('image/')) {
throw new Error('Please select an image file');
}
// Upload timeout
const timeout = setTimeout(() => {
throw new Error('Upload timeout (30s limit)');
}, 28000); // 28s (before Cloudflare's 30s timeout)
try {
await fetch(uploadURL, { body: formData });
clearTimeout(timeout);
} catch (error) {
clearTimeout(timeout);
throw error;
}
```
---
## Custom ID Support
```typescript
// Generate upload URL with custom ID
const response = await fetch(directUploadURL, {
method: 'POST',
headers: { ... },
body: JSON.stringify({
id: `user-${userId}-profile`, // Custom ID
metadata: { userId }
})
});
// Access with custom ID
const imageURL = `https://imagedelivery.net/${accountHash}/user-${userId}-profile/public`;
```
**Note**: Custom IDs cannot be used with `requireSignedURLs=true`.
---
## Expiry Configuration
```typescript
// Default: 30 minutes
// Min: 2 minutes
// Max: 6 hours
const expiry = new Date(Date.now() + 6 * 60 * 60 * 1000); // 6 hours
const response = await fetch(directUploadURL, {
method: 'POST',
body: JSON.stringify({
expiry: expiry.toISOString()
})
});
```
---
## Security Best Practices
1. **Never expose API token to browser**: Backend-only
2. **Validate file type and size**: Frontend and backend
3. **Rate limit upload URL generation**: Prevent abuse
4. **Associate uploads with users**: Track in metadata
5. **Implement webhooks**: Verify successful uploads
6. **Set reasonable expiry**: 30min-1hr for most cases
7. **Use signed URLs for private content**: `requireSignedURLs=true`
---
## Testing
```bash
# Test backend endpoint
curl -X POST http://localhost:8787/api/upload-url \
-H "Content-Type: application/json" \
-d '{"userId":"test-user"}'
# Test upload (replace UPLOAD_URL with response)
curl -X POST "UPLOAD_URL" \
-F "file=@./test-image.jpg"
```
---
## Official Documentation
- **Direct Creator Upload**: https://developers.cloudflare.com/images/upload-images/direct-creator-upload/
- **Configure Webhooks**: https://developers.cloudflare.com/images/manage-images/configure-webhooks/
references/format-optimization.md
# Format Optimization
Complete guide to automatic WebP/AVIF conversion and format selection.
---
## format=auto (Recommended)
Automatically serve optimal format based on browser support.
**Priority**:
1. **AVIF** - Best compression (Chrome, Edge)
2. **WebP** - Good compression (Safari, Firefox)
3. **Original format** - Fallback (older browsers)
**Usage**:
```typescript
// URL format
/cdn-cgi/image/width=800,quality=85,format=auto/image.jpg
// Workers format
fetch(imageURL, {
cf: {
image: {
width: 800,
quality: 85,
format: 'auto'
}
}
});
// Cloudflare Images
https://imagedelivery.net/HASH/ID/w=800,q=85,f=auto
```
---
## Browser Support Detection
Cloudflare automatically checks the `Accept` header.
**Chrome/Edge**:
```
Accept: image/avif,image/webp,image/apng,image/*,*/*
```
→ Serves AVIF
**Safari**:
```
Accept: image/webp,image/apng,image/*,*/*
```
→ Serves WebP
**Older browsers**:
```
Accept: image/jpeg,image/png,image/*,*/*
```
→ Serves original format (JPEG)
---
## Manual Format Selection
### In URL Transformations
```html
<!-- AVIF (best compression) -->
<img src="/cdn-cgi/image/format=avif/image.jpg" />
<!-- WebP (good compression, wide support) -->
<img src="/cdn-cgi/image/format=webp/image.jpg" />
<!-- JPEG (progressive) -->
<img src="/cdn-cgi/image/format=jpeg/image.jpg" />
<!-- Baseline JPEG (older devices) -->
<img src="/cdn-cgi/image/format=baseline-jpeg/image.jpg" />
```
### In Workers
```typescript
// Get optimal format from Accept header
function getOptimalFormat(request: Request): 'avif' | 'webp' | 'auto' {
const accept = request.headers.get('accept') || '';
if (/image\/avif/.test(accept)) {
return 'avif';
} else if (/image\/webp/.test(accept)) {
return 'webp';
}
return 'auto'; // Cloudflare decides
}
return fetch(imageURL, {
cf: {
image: {
format: getOptimalFormat(request)
}
}
});
```
---
## Format Comparison
| Format | Compression | Quality | Support | Use Case |
|--------|-------------|---------|---------|----------|
| **AVIF** | Best (~50% smaller) | Excellent | Modern browsers | First choice (auto) |
| **WebP** | Good (~30% smaller) | Excellent | Wide support | Fallback from AVIF |
| **JPEG** | Standard | Good | Universal | Fallback, photos |
| **PNG** | Lossless | Lossless | Universal | Graphics, transparency |
**File Size Example** (1920x1080 photo):
- Original JPEG: 500 KB
- WebP: ~350 KB (30% smaller)
- AVIF: ~250 KB (50% smaller)
---
## Progressive vs Baseline JPEG
**Progressive JPEG** (default):
- Loads in multiple passes (low→high quality)
- Better for slow connections
- Slightly larger file size
**Baseline JPEG**:
- Loads top-to-bottom
- Better for older devices
- Slightly smaller file size
**Usage**:
```
format=jpeg → Progressive JPEG
format=baseline-jpeg → Baseline JPEG
```
---
## WebP Compression Modes
```typescript
// Fast compression (faster encoding, larger file)
fetch(imageURL, {
cf: {
image: {
format: 'webp',
compression: 'fast'
}
}
});
// Lossless WebP (no quality loss, larger file)
fetch(imageURL, {
cf: {
image: {
format: 'webp',
compression: 'lossless'
}
}
});
```
---
## Responsive Images with format=auto
```html
<picture>
<!-- Explicit AVIF for modern browsers -->
<source
type="image/avif"
srcset="
https://imagedelivery.net/HASH/ID/w=480,f=avif 480w,
https://imagedelivery.net/HASH/ID/w=1920,f=avif 1920w
"
/>
<!-- WebP fallback -->
<source
type="image/webp"
srcset="
https://imagedelivery.net/HASH/ID/w=480,f=webp 480w,
https://imagedelivery.net/HASH/ID/w=1920,f=webp 1920w
"
/>
<!-- JPEG fallback -->
<img
srcset="
https://imagedelivery.net/HASH/ID/w=480,f=jpeg 480w,
https://imagedelivery.net/HASH/ID/w=1920,f=jpeg 1920w
"
src="https://imagedelivery.net/HASH/ID/w=1920,f=jpeg"
alt="Responsive image with format fallbacks"
/>
</picture>
<!-- OR: Let format=auto handle it -->
<img
srcset="
https://imagedelivery.net/HASH/ID/w=480,f=auto 480w,
https://imagedelivery.net/HASH/ID/w=1920,f=auto 1920w
"
src="https://imagedelivery.net/HASH/ID/w=1920,f=auto"
alt="Auto-format responsive image"
/>
```
---
## Quality Recommendations by Format
```typescript
const qualitySettings = {
jpeg: 85, // Standard for photos
webp: 85, // Same as JPEG
avif: 85, // AVIF efficient at same quality
png: undefined, // Lossless (quality N/A)
graphics: 95 // High quality for logos/text
};
// Photos
/cdn-cgi/image/width=800,quality=85,format=auto/photo.jpg
// Graphics with text
/cdn-cgi/image/width=800,quality=95,format=auto/logo.png
// Thumbnails (lower quality acceptable)
/cdn-cgi/image/width=300,quality=75,format=auto/thumb.jpg
```
---
## Animation Support
**GIF**:
```
format=auto → Still GIF or first frame
anim=true → Preserve animation
```
**Animated WebP**:
```typescript
fetch(animatedGif, {
cf: {
image: {
format: 'webp',
anim: true // Preserve animation
}
}
});
```
---
## Metadata Handling
**Strip metadata** (smaller file size):
```
metadata=none
```
**Keep copyright** (default for JPEG):
```
metadata=copyright
```
**Keep all EXIF** (GPS, camera settings):
```
metadata=keep
```
**Example**:
```
/cdn-cgi/image/width=800,format=auto,metadata=none/photo.jpg
```
---
## Cost Optimization
1. **Use format=auto**: Smallest files = less bandwidth
2. **Reasonable quality**: 80-85 for photos, 90-95 for graphics
3. **Strip metadata**: `metadata=none` for public images
4. **Cache at edge**: First transformation billable, subsequent free
5. **WebP animations**: Convert GIF to animated WebP (smaller)
---
## Testing Format Support
```html
<script>
// Check AVIF support
const avifSupport = document.createElement('canvas')
.toDataURL('image/avif').indexOf('data:image/avif') === 0;
// Check WebP support
const webpSupport = document.createElement('canvas')
.toDataURL('image/webp').indexOf('data:image/webp') === 0;
console.log('AVIF:', avifSupport); // true in Chrome/Edge
console.log('WebP:', webpSupport); // true in modern browsers
</script>
```
**But**: Let Cloudflare handle this with `format=auto`!
---
## Common Patterns
### Hero Image
```
width=1920,height=1080,fit=cover,quality=85,format=auto,metadata=none
```
### Thumbnail
```
width=300,height=300,fit=cover,quality=75,format=auto,metadata=none
```
### Avatar
```
width=200,height=200,fit=cover,gravity=face,quality=90,format=auto
```
### Product Photo
```
width=800,height=800,fit=contain,quality=90,sharpen=2,format=auto
```
### Blur Placeholder (LQIP)
```
width=50,quality=10,blur=20,format=webp,metadata=none
```
---
## Best Practices
1. **Always use format=auto**: Let Cloudflare optimize
2. **Quality 80-90**: Balance file size and quality
3. **Strip unnecessary metadata**: Smaller files
4. **Test on real devices**: Verify format delivery
5. **Monitor bandwidth**: Check Cloudflare Analytics
6. **Use WebP for animations**: Smaller than GIF
7. **Progressive JPEG for photos**: Better perceived load time
---
## Official Documentation
- **Transform via URL**: https://developers.cloudflare.com/images/transform-images/transform-via-url/
- **Supported Formats**: https://developers.cloudflare.com/images/transform-images/#supported-formats-and-limitations
references/framework-integration.md
# Framework Integration Guide for Cloudflare Images
Complete integration patterns for Next.js, Remix, Astro, and other popular frameworks with Cloudflare Images.
---
## Next.js Integration
### Next.js Image Component
**Problem**: Next.js `<Image>` component expects specific loader format
**Solution**: Custom Cloudflare Images loader
#### Loader Configuration
**next.config.js**:
```javascript
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
loader: 'custom',
loaderFile: './lib/cloudflare-image-loader.ts',
},
};
module.exports = nextConfig;
```
**lib/cloudflare-image-loader.ts**:
```typescript
export default function cloudflareLoader({
src,
width,
quality
}: {
src: string;
width: number;
quality?: number;
}) {
const params = new URLSearchParams();
params.set('width', width.toString());
if (quality) {
params.set('quality', quality.toString());
}
params.set('format', 'auto');
// Extract image ID from src (assuming src is just the image ID)
const imageId = src.startsWith('/') ? src.slice(1) : src;
// Use your custom domain or imagedelivery.net
const DOMAIN = process.env.NEXT_PUBLIC_CF_IMAGES_DOMAIN;
const ACCOUNT_HASH = process.env.NEXT_PUBLIC_CF_ACCOUNT_HASH;
const VARIANT = 'public'; // or make this dynamic
if (DOMAIN) {
return `https://${DOMAIN}/${imageId}/${VARIANT}?${params}`;
}
return `https://imagedelivery.net/${ACCOUNT_HASH}/${imageId}/${VARIANT}?${params}`;
}
```
**.env.local**:
```env
NEXT_PUBLIC_CF_IMAGES_DOMAIN=images.yourdomain.com
# OR use default
NEXT_PUBLIC_CF_ACCOUNT_HASH=Vi7wi5KSItxGFsWRG2Us6Q
```
#### Usage in Components
```tsx
// app/page.tsx
import Image from 'next/image';
export default function HomePage() {
return (
<div>
<Image
src="2cdc28f0-017a-49c4-9ed7-87056c83901" // Image ID
alt="Product photo"
width={800}
height={600}
priority
/>
</div>
);
}
```
### App Router with Server Components
**app/components/CloudflareImage.tsx**:
```tsx
import Image from 'next/image';
interface CloudflareImageProps {
imageId: string;
alt: string;
width: number;
height: number;
variant?: string;
priority?: boolean;
className?: string;
}
export function CloudflareImage({
imageId,
alt,
width,
height,
variant = 'public',
priority = false,
className
}: CloudflareImageProps) {
// src is just image ID, loader handles the rest
return (
<Image
src={imageId}
alt={alt}
width={width}
height={height}
priority={priority}
className={className}
/>
);
}
// Usage
<CloudflareImage
imageId="2cdc28f0-017a-49c4-9ed7-87056c83901"
alt="Hero image"
width={1920}
height={1080}
priority
/>
```
### API Route for Uploads
**app/api/upload/route.ts**:
```typescript
import { NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
try {
const formData = await request.formData();
const file = formData.get('file') as File;
if (!file) {
return NextResponse.json(
{ error: 'No file provided' },
{ status: 400 }
);
}
// Upload to Cloudflare Images
const cloudflareFormData = new FormData();
cloudflareFormData.append('file', file);
const uploadResponse = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${process.env.CF_ACCOUNT_ID}/images/v1`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.CF_API_TOKEN}`
},
body: cloudflareFormData
}
);
const result = await uploadResponse.json();
if (!result.success) {
return NextResponse.json(
{ error: 'Upload failed' },
{ status: 500 }
);
}
return NextResponse.json({
imageId: result.result.id,
variants: result.result.variants
});
} catch (error) {
console.error('Upload error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
```
---
## Remix Integration
### Loader Pattern
**app/routes/_index.tsx**:
```typescript
import { json, type LoaderFunctionArgs } from '@remix-run/node';
import { useLoaderData } from '@remix-run/react';
// Load image metadata from your database
export async function loader({ params }: LoaderFunctionArgs) {
const images = await db.images.findMany({
select: {
id: true,
cloudflareId: true,
alt: true
},
take: 10
});
return json({ images });
}
export default function Index() {
const { images } = useLoaderData<typeof loader>();
return (
<div className="grid grid-cols-3 gap-4">
{images.map((image) => (
<img
key={image.id}
src={`https://images.yourdomain.com/${image.cloudflareId}/thumbnail`}
alt={image.alt}
loading="lazy"
/>
))}
</div>
);
}
```
### Action for Uploads
**app/routes/upload.tsx**:
```typescript
import { json, type ActionFunctionArgs } from '@remix-run/node';
import { Form, useActionData, useNavigation } from '@remix-run/react';
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const file = formData.get('file') as File;
if (!file || file.size === 0) {
return json({ error: 'No file selected' }, { status: 400 });
}
try {
// Upload to Cloudflare Images
const cloudflareFormData = new FormData();
cloudflareFormData.append('file', file);
const uploadResponse = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${process.env.CF_ACCOUNT_ID}/images/v1`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.CF_API_TOKEN}`
},
body: cloudflareFormData
}
);
const result = await uploadResponse.json();
if (!result.success) {
return json({ error: 'Upload failed' }, { status: 500 });
}
// Save to database
await db.images.create({
data: {
cloudflareId: result.result.id,
filename: file.name,
url: result.result.variants[0]
}
});
return json({
success: true,
imageId: result.result.id
});
} catch (error) {
console.error('Upload error:', error);
return json({ error: 'Upload failed' }, { status: 500 });
}
}
export default function UploadPage() {
const actionData = useActionData<typeof action>();
const navigation = useNavigation();
const isUploading = navigation.state === 'submitting';
return (
<Form method="post" encType="multipart/form-data">
<input
type="file"
name="file"
accept="image/*"
required
/>
<button type="submit" disabled={isUploading}>
{isUploading ? 'Uploading...' : 'Upload Image'}
</button>
{actionData?.error && (
<p className="error">{actionData.error}</p>
)}
{actionData?.success && (
<p className="success">Image uploaded successfully!</p>
)}
</Form>
);
}
```
### Image Component
**app/components/CloudflareImage.tsx**:
```typescript
interface CloudflareImageProps {
imageId: string;
variant?: string;
width?: number;
quality?: number;
format?: 'auto' | 'webp' | 'avif';
alt: string;
className?: string;
}
export function CloudflareImage({
imageId,
variant = 'public',
width,
quality = 85,
format = 'auto',
alt,
className
}: CloudflareImageProps) {
const params = new URLSearchParams();
if (width) params.set('width', width.toString());
params.set('quality', quality.toString());
params.set('format', format);
const DOMAIN = 'images.yourdomain.com'; // or from env
const url = `https://${DOMAIN}/${imageId}/${variant}?${params}`;
return (
<img
src={url}
alt={alt}
className={className}
loading="lazy"
/>
);
}
```
---
## Astro Integration
### Component
**src/components/CloudflareImage.astro**:
```astro
---
interface Props {
imageId: string;
variant?: string;
width?: number;
quality?: number;
format?: 'auto' | 'webp' | 'avif';
alt: string;
class?: string;
}
const {
imageId,
variant = 'public',
width,
quality = 85,
format = 'auto',
alt,
class: className
} = Astro.props;
const params = new URLSearchParams();
if (width) params.set('width', width.toString());
params.set('quality', quality.toString());
params.set('format', format);
const DOMAIN = import.meta.env.PUBLIC_CF_IMAGES_DOMAIN || 'imagedelivery.net';
const ACCOUNT_HASH = import.meta.env.PUBLIC_CF_ACCOUNT_HASH;
const url = DOMAIN.includes('imagedelivery.net')
? `https://${DOMAIN}/${ACCOUNT_HASH}/${imageId}/${variant}?${params}`
: `https://${DOMAIN}/${imageId}/${variant}?${params}`;
---
<img
src={url}
alt={alt}
class={className}
loading="lazy"
/>
```
**.env**:
```env
PUBLIC_CF_IMAGES_DOMAIN=images.yourdomain.com
PUBLIC_CF_ACCOUNT_HASH=Vi7wi5KSItxGFsWRG2Us6Q
```
### Usage in Pages
**src/pages/index.astro**:
```astro
---
import CloudflareImage from '../components/CloudflareImage.astro';
import { getImages } from '../lib/db';
const images = await getImages();
---
<html>
<head>
<title>Gallery</title>
</head>
<body>
<div class="gallery">
{images.map((image) => (
<CloudflareImage
imageId={image.cloudflareId}
variant="thumbnail"
width={400}
alt={image.alt}
/>
))}
</div>
</body>
</html>
```
### API Endpoint for Uploads
**src/pages/api/upload.ts**:
```typescript
import type { APIRoute } from 'astro';
export const POST: APIRoute = async ({ request }) => {
try {
const formData = await request.formData();
const file = formData.get('file') as File;
if (!file) {
return new Response(
JSON.stringify({ error: 'No file provided' }),
{ status: 400 }
);
}
const cloudflareFormData = new FormData();
cloudflareFormData.append('file', file);
const uploadResponse = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${import.meta.env.CF_ACCOUNT_ID}/images/v1`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${import.meta.env.CF_API_TOKEN}`
},
body: cloudflareFormData
}
);
const result = await uploadResponse.json();
if (!result.success) {
return new Response(
JSON.stringify({ error: 'Upload failed' }),
{ status: 500 }
);
}
return new Response(
JSON.stringify({
imageId: result.result.id,
variants: result.result.variants
}),
{ status: 200 }
);
} catch (error) {
console.error('Upload error:', error);
return new Response(
JSON.stringify({ error: 'Internal server error' }),
{ status: 500 }
);
}
};
```
---
## SvelteKit Integration
### Image Component
**src/lib/components/CloudflareImage.svelte**:
```svelte
<script lang="ts">
export let imageId: string;
export let variant = 'public';
export let width: number | undefined = undefined;
export let quality = 85;
export let format: 'auto' | 'webp' | 'avif' = 'auto';
export let alt: string;
export let className = '';
const DOMAIN = import.meta.env.VITE_CF_IMAGES_DOMAIN;
$: params = new URLSearchParams();
$: {
if (width) params.set('width', width.toString());
params.set('quality', quality.toString());
params.set('format', format);
}
$: imageUrl = `https://${DOMAIN}/${imageId}/${variant}?${params}`;
</script>
<img src={imageUrl} {alt} class={className} loading="lazy" />
```
### Upload Endpoint
**src/routes/api/upload/+server.ts**:
```typescript
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ request }) => {
try {
const formData = await request.formData();
const file = formData.get('file') as File;
if (!file) {
return json({ error: 'No file provided' }, { status: 400 });
}
const cloudflareFormData = new FormData();
cloudflareFormData.append('file', file);
const uploadResponse = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${process.env.CF_ACCOUNT_ID}/images/v1`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.CF_API_TOKEN}`
},
body: cloudflareFormData
}
);
const result = await uploadResponse.json();
if (!result.success) {
return json({ error: 'Upload failed' }, { status: 500 });
}
return json({
imageId: result.result.id,
variants: result.result.variants
});
} catch (error) {
console.error('Upload error:', error);
return json({ error: 'Internal server error' }, { status: 500 });
}
};
```
---
## Environment Variables
### Development (.env.local)
```env
# Cloudflare Images Configuration
CF_ACCOUNT_ID=your_account_id
CF_API_TOKEN=your_api_token
CF_ACCOUNT_HASH=your_account_hash
# Public variables (exposed to browser)
NEXT_PUBLIC_CF_IMAGES_DOMAIN=images.yourdomain.com
# OR
VITE_CF_IMAGES_DOMAIN=images.yourdomain.com
# OR
PUBLIC_CF_IMAGES_DOMAIN=images.yourdomain.com
```
### Production
Set environment variables in deployment platform:
- **Vercel**: Project Settings → Environment Variables
- **Netlify**: Site Settings → Build & Deploy → Environment
- **Cloudflare Pages**: Settings → Environment Variables
---
## Best Practices
### 1. Use Environment Variables
```typescript
// ✅ GOOD
const DOMAIN = process.env.NEXT_PUBLIC_CF_IMAGES_DOMAIN;
// ❌ BAD
const DOMAIN = 'images.yourdomain.com'; // Hardcoded
```
### 2. Lazy Loading
```tsx
// Next.js
<Image src={imageId} loading="lazy" />
// React/Remix
<img src={url} loading="lazy" />
```
### 3. Responsive Images
```tsx
<CloudflareImage
imageId={id}
width={800}
sizes="(max-width: 768px) 100vw, 50vw"
/>
```
### 4. Error Handling
```typescript
try {
const response = await uploadImage(file);
if (!response.success) {
throw new Error('Upload failed');
}
} catch (error) {
console.error('Upload error:', error);
// Show user-friendly error message
}
```
---
## Performance Optimization
### 1. Format Auto-Detection
```typescript
// Always use format=auto for automatic WebP/AVIF
params.set('format', 'auto');
```
### 2. Quality Settings
```typescript
// Balance quality and file size
const quality = width > 1200 ? 90 : 85;
```
### 3. Preloading Critical Images
**Next.js**:
```tsx
<Image src={heroImageId} priority />
```
**HTML**:
```html
<link
rel="preload"
as="image"
href="https://images.yourdomain.com/hero-id/public"
/>
```
---
## TypeScript Types
**types/cloudflare-images.ts**:
```typescript
export interface CloudflareImageProps {
imageId: string;
variant?: string;
width?: number;
height?: number;
quality?: number;
format?: 'auto' | 'webp' | 'avif' | 'jpeg' | 'png';
fit?: 'scale-down' | 'contain' | 'cover' | 'crop' | 'pad';
alt: string;
loading?: 'lazy' | 'eager';
className?: string;
}
export interface CloudflareUploadResult {
success: boolean;
result: {
id: string;
filename: string;
uploaded: string;
requireSignedURLs: boolean;
variants: string[];
};
}
```
---
## Related References
- **Upload API**: See `references/api-reference.md`
- **Custom Domains**: See `references/custom-domains.md`
- **Transformations**: See `references/transformation-options.md`
---
## Official Documentation
- **Next.js Image**: https://nextjs.org/docs/api-reference/next/image
- **Remix**: https://remix.run/docs
- **Astro**: https://docs.astro.build
- **SvelteKit**: https://kit.svelte.dev/docs
references/overlays-watermarks.md
# Overlays and Watermarks for Cloudflare Images
Guide to adding overlays, watermarks, and branding elements to images using Cloudflare Images and related techniques.
---
## Watermarking Strategies
While Cloudflare Images doesn't have a built-in `draw` or `overlay` transformation parameter, you can implement watermarking through several approaches:
### Strategy 1: Pre-Processing (Recommended)
**Add watermarks before uploading to Cloudflare Images**:
```typescript
// Using Canvas API (Browser/Node.js)
async function addWatermark(
imageFile: File,
watermarkUrl: string
): Promise<Blob> {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d')!;
// Load main image
const img = await createImageBitmap(imageFile);
canvas.width = img.width;
canvas.height = img.height;
// Draw main image
ctx.drawImage(img, 0, 0);
// Load and draw watermark
const watermark = new Image();
watermark.src = watermarkUrl;
await watermark.decode();
const wmWidth = canvas.width * 0.2; // 20% of image width
const wmHeight = (watermark.height / watermark.width) * wmWidth;
const x = canvas.width - wmWidth - 20; // 20px padding
const y = canvas.height - wmHeight - 20;
ctx.globalAlpha = 0.5; // 50% opacity
ctx.drawImage(watermark, x, y, wmWidth, wmHeight);
return new Promise((resolve) => {
canvas.toBlob((blob) => resolve(blob!), 'image/jpeg', 0.9);
});
}
// Usage
const watermarkedImage = await addWatermark(file, '/logo.png');
// Upload to Cloudflare Images
const formData = new FormData();
formData.append('file', watermarkedImage);
await fetch(`https://api.cloudflare.com/client/v4/accounts/${accountId}/images/v1`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${apiToken}` },
body: formData
});
```
### Strategy 2: Cloudflare Workers Image Manipulation
**Apply watermarks using Workers with HTMLRewriter or external libraries**:
```typescript
// Using Workers with image manipulation
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
// Check if watermark requested
if (!url.searchParams.has('watermark')) {
return fetch(request);
}
// Fetch base image from Cloudflare Images
const imageResponse = await fetch(
`https://imagedelivery.net/${env.ACCOUNT_HASH}/${url.pathname.split('/')[1]}/public`,
{ cf: { image: { quality: 85, format: 'auto' } } }
);
const imageBuffer = await imageResponse.arrayBuffer();
// TODO: Use image processing library to add watermark
// (Note: This requires a WebAssembly image processing library)
return new Response(imageBuffer, {
headers: { 'Content-Type': 'image/jpeg' }
});
}
};
```
**Note**: Full image manipulation in Workers requires WebAssembly libraries (e.g., compiled ImageMagick, Sharp).
### Strategy 3: CSS Overlays (Client-Side)
**Add watermarks using CSS layers**:
```html
<style>
.watermarked-image {
position: relative;
display: inline-block;
}
.watermarked-image::after {
content: '© Your Company';
position: absolute;
bottom: 10px;
right: 10px;
color: white;
background: rgba(0, 0, 0, 0.5);
padding: 5px 10px;
border-radius: 3px;
font-size: 14px;
font-weight: bold;
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.8);
}
</style>
<div class="watermarked-image">
<img src="https://images.yourdomain.com/photo-id/public" alt="Product" />
</div>
```
**React Component**:
```tsx
interface WatermarkedImageProps {
imageId: string;
variant?: string;
watermarkText?: string;
alt: string;
}
export function WatermarkedImage({
imageId,
variant = 'public',
watermarkText = '© Your Company',
alt
}: WatermarkedImageProps) {
return (
<div className="relative inline-block">
<img
src={`https://images.yourdomain.com/${imageId}/${variant}`}
alt={alt}
className="block"
/>
<div className="absolute bottom-2 right-2 bg-black/50 text-white px-3 py-1 rounded text-sm font-semibold">
{watermarkText}
</div>
</div>
);
}
```
---
## Server-Side Watermarking
### Using Sharp (Node.js)
```typescript
import sharp from 'sharp';
import { readFile } from 'fs/promises';
async function addWatermark(
imagePath: string,
watermarkPath: string,
outputPath: string
) {
const image = sharp(imagePath);
const metadata = await image.metadata();
const watermark = await sharp(watermarkPath)
.resize({ width: Math.floor(metadata.width! * 0.2) })
.toBuffer();
await image
.composite([
{
input: watermark,
gravity: 'southeast',
blend: 'over'
}
])
.toFile(outputPath);
}
// Usage in upload workflow
await addWatermark(
'uploads/photo.jpg',
'watermarks/logo.png',
'processed/photo-watermarked.jpg'
);
// Then upload to Cloudflare Images
const watermarkedImage = await readFile('processed/photo-watermarked.jpg');
// Upload via API...
```
### Using ImageMagick
```bash
#!/bin/bash
# Add watermark to image
convert input.jpg \
watermark.png \
-gravity southeast \
-geometry +20+20 \
-composite \
output.jpg
# Upload to Cloudflare Images
curl --request POST \
https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/images/v1 \
--header "Authorization: Bearer $API_TOKEN" \
--form "file=@output.jpg"
```
---
## Logo Placement Patterns
### Corner Watermarks
```typescript
// Bottom-right (most common)
{
position: 'absolute',
bottom: '10px',
right: '10px'
}
// Top-right
{
position: 'absolute',
top: '10px',
right: '10px'
}
// Bottom-left
{
position: 'absolute',
bottom: '10px',
left: '10px'
}
```
### Center Watermarks
```typescript
// Centered
{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)'
}
// Tiled (repeated pattern)
// Use CSS or canvas to repeat watermark across image
```
---
## Opacity and Blending
### CSS Approach
```css
.watermark {
position: absolute;
opacity: 0.3; /* 30% opacity */
mix-blend-mode: multiply; /* Blend with background */
}
/* Alternative blend modes */
mix-blend-mode: overlay;
mix-blend-mode: soft-light;
mix-blend-mode: lighten;
```
### Canvas Approach
```typescript
ctx.globalAlpha = 0.3; // 30% opacity
ctx.globalCompositeOperation = 'multiply'; // Blend mode
ctx.drawImage(watermark, x, y, width, height);
```
---
## Batch Watermarking
### Process Multiple Images
```typescript
async function batchWatermark(
imageFiles: File[],
watermarkUrl: string
): Promise<string[]> {
const uploadedIds: string[] = [];
for (const file of imageFiles) {
// Add watermark
const watermarked = await addWatermark(file, watermarkUrl);
// Upload to Cloudflare Images
const formData = new FormData();
formData.append('file', watermarked);
const response = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${accountId}/images/v1`,
{
method: 'POST',
headers: { 'Authorization': `Bearer ${apiToken}` },
body: formData
}
);
const result = await response.json();
uploadedIds.push(result.result.id);
}
return uploadedIds;
}
```
---
## Dynamic Watermarks
### User-Specific Watermarks
```typescript
async function addUserWatermark(
imageFile: File,
userId: string
): Promise<Blob> {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d')!;
const img = await createImageBitmap(imageFile);
canvas.width = img.width;
canvas.height = img.height;
ctx.drawImage(img, 0, 0);
// Dynamic text watermark
ctx.font = 'bold 24px Arial';
ctx.fillStyle = 'rgba(255, 255, 255, 0.7)';
ctx.strokeStyle = 'rgba(0, 0, 0, 0.7)';
ctx.lineWidth = 2;
const text = `User: ${userId}`;
const x = canvas.width - ctx.measureText(text).width - 20;
const y = canvas.height - 20;
ctx.strokeText(text, x, y);
ctx.fillText(text, x, y);
return new Promise((resolve) => {
canvas.toBlob((blob) => resolve(blob!), 'image/jpeg', 0.9);
});
}
```
---
## Protecting Images from Download
While watermarks deter casual copying, determined users can still download images. Additional protection strategies:
### 1. Disable Right-Click (Limited Effectiveness)
```html
<img
src="https://images.yourdomain.com/photo-id/public"
oncontextmenu="return false;"
alt="Protected image"
/>
```
### 2. Use Transparent Overlay
```html
<div style="position: relative;">
<img src="https://images.yourdomain.com/photo-id/public" alt="Product" />
<div style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; cursor: default;"></div>
</div>
```
### 3. Signed URLs with Expiry
```typescript
// Generate temporary URL
const signedUrl = generateSignedUrl(imageId, {
expiresIn: 3600 // 1 hour
});
// URL becomes invalid after expiry
<img src={signedUrl} alt="Protected image" />
```
See `references/signed-urls-guide.md` for implementation.
---
## Copyright Notices
### Text-Based Watermarks
```typescript
function addCopyrightNotice(
ctx: CanvasRenderingContext2D,
width: number,
height: number,
copyrightText: string
) {
ctx.font = 'bold 16px Arial';
ctx.fillStyle = 'rgba(255, 255, 255, 0.8)';
ctx.strokeStyle = 'rgba(0, 0, 0, 0.8)';
ctx.lineWidth = 3;
ctx.textAlign = 'center';
const x = width / 2;
const y = height - 20;
ctx.strokeText(copyrightText, x, y);
ctx.fillText(copyrightText, x, y);
}
// Usage
addCopyrightNotice(ctx, canvas.width, canvas.height, '© 2025 Your Company. All Rights Reserved.');
```
---
## Performance Considerations
### Pre-Processing vs Runtime
**Pre-Processing** (Recommended):
- ✅ Watermark added once during upload
- ✅ No runtime performance cost
- ✅ Images cached with watermark
- ✅ Better for high-traffic sites
**Runtime** (CSS/Canvas):
- ⚠️ Watermark added on every page load
- ⚠️ Client-side processing (can be removed)
- ⚠️ Not embedded in image file
- ✅ Easier to update watermark design
**Recommendation**: Pre-process watermarks for production use.
---
## Best Practices
### 1. Balance Visibility and Aesthetics
```
✅ Subtle logo in corner (30-50% opacity)
❌ Large centered watermark obscuring content
```
### 2. Consistent Placement
```
✅ Always bottom-right for all product images
❌ Random placement across different images
```
### 3. Appropriate Size
```
✅ Watermark 10-20% of image dimensions
❌ Tiny watermark (ineffective)
❌ Huge watermark (poor UX)
```
### 4. Use Vector Logos
```
✅ SVG or high-res PNG for watermarks
✅ Scale gracefully to any image size
❌ Low-res watermark on high-res images
```
---
## Related References
- **Upload API**: See `references/api-reference.md`
- **Transformations**: See `references/transformation-options.md`
- **Signed URLs**: See `references/signed-urls-guide.md`
---
## External Tools
**Image Processing Libraries**:
- **Sharp** (Node.js): https://sharp.pixelplumbing.com/
- **Jimp** (Node.js, pure JS): https://github.com/jimp-dev/jimp
- **Pillow** (Python): https://python-pillow.org/
- **ImageMagick** (CLI): https://imagemagick.org/
**Browser APIs**:
- **Canvas API**: https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API
- **createImageBitmap**: https://developer.mozilla.org/en-US/docs/Web/API/createImageBitmap
references/polish-compression.md
# Polish Compression for Cloudflare Images
Complete guide to Cloudflare's Polish feature for automatic image compression and optimization.
---
## What is Polish?
Polish is Cloudflare's automatic image compression feature that optimizes images served through Cloudflare's network **without modifying the original files**. It works in real-time as images are requested.
**Key Benefits**:
- Reduce file sizes by 35-65%
- Faster page load times
- Lower bandwidth costs
- No original file modification
- Automatic format conversion (WebP, AVIF)
---
## Polish Modes
### 1. Lossless Mode
**What it does**:
- Removes unnecessary metadata (EXIF, comments)
- Optimizes compression algorithms
- **No visual quality loss**
- Typical savings: 10-35%
**Best for**:
- Professional photography
- Medical/scientific images
- Legal documents
- Archival images
- When quality is critical
**Enable**:
Dashboard → Speed → Optimization → Polish → **Lossless**
**Example Savings**:
```
Original JPEG: 2.4 MB → Lossless: 1.8 MB (25% smaller)
Original PNG: 1.2 MB → Lossless: 0.9 MB (25% smaller)
```
### 2. Lossy Mode
**What it does**:
- Aggressive compression
- Slight quality reduction (often imperceptible)
- Typical savings: 35-65%
**Best for**:
- Web images
- Thumbnails
- Product photos
- User avatars
- Marketing materials
- Most web content
**Enable**:
Dashboard → Speed → Optimization → Polish → **Lossy**
**Example Savings**:
```
Original JPEG: 2.4 MB → Lossy: 0.9 MB (62% smaller)
Original PNG: 1.2 MB → Lossy: 0.5 MB (58% smaller)
```
### 3. WebP Conversion
**What it does**:
- Converts JPEG/PNG to WebP format
- 25-35% smaller than equivalent JPEG
- Supported by 95%+ of browsers
- Falls back to original for unsupported browsers
**Enable**:
Dashboard → Speed → Optimization → Polish → **Lossy** + **WebP**
**Example Savings**:
```
Original JPEG: 2.4 MB → WebP: 0.7 MB (70% smaller)
Original PNG: 1.2 MB → WebP: 0.4 MB (67% smaller)
```
**Browser Support**:
- Chrome, Edge, Firefox, Safari (all modern versions)
- Automatic fallback for older browsers
---
## Configuration
### Dashboard Configuration
1. **Navigate to Speed Settings**:
Dashboard → Your Domain → Speed → Optimization
2. **Select Polish Mode**:
- **Off**: No compression
- **Lossless**: Metadata removal only
- **Lossy**: Aggressive compression
3. **Enable WebP** (Optional):
- Checkbox: "WebP"
- Automatically converts to WebP for supported browsers
4. **Save Changes**:
Takes effect immediately
### API Configuration
```bash
curl --request PATCH \
https://api.cloudflare.com/client/v4/zones/{zone_id}/settings/polish \
--header "Authorization: Bearer <API_TOKEN>" \
--header "Content-Type: application/json" \
--data '{
"value": "lossy"
}'
```
**Values**:
- `"off"` - Polish disabled
- `"lossless"` - Lossless mode
- `"lossy"` - Lossy mode
---
## Polish vs Cloudflare Images Transformations
### Polish (Network-Level)
**Applies to**:
- All images served through Cloudflare (proxied)
- Images on your origin server
- Any image passing through Cloudflare's network
**Configuration**: Zone-wide (applies to entire domain)
**Use when**:
- You want automatic optimization for all images
- You don't want to change image URLs
- You have legacy images on your origin
### Cloudflare Images Transformations (Service-Level)
**Applies to**:
- Images uploaded to Cloudflare Images
- Served via imagedelivery.net or custom domain
**Configuration**: Per-request via URL parameters
**Use when**:
- You need specific transformations (resize, crop)
- You want precise control over format/quality
- You're using Cloudflare Images storage
### Combined Approach (Best Practice)
```
1. Use Cloudflare Images for new uploads
→ Precise transformations via URL parameters
2. Enable Polish for existing origin images
→ Automatic optimization without URL changes
3. Use format=auto in Cloudflare Images
→ Automatic WebP/AVIF conversion
```
---
## Format Comparison
### WebP
**Advantages**:
- 25-35% smaller than JPEG
- Supports transparency (like PNG)
- Supports animation (like GIF)
- Widely supported (95%+ browsers)
**File Size Comparison** (1920x1080 image):
```
JPEG (quality 85): 180 KB
WebP (quality 85): 120 KB (33% smaller)
WebP (quality 75): 85 KB (53% smaller)
```
### AVIF
**Advantages**:
- 30-50% smaller than WebP
- Better quality at lower file sizes
- Supports HDR
- Modern format
**Browser Support**:
- Chrome 85+, Firefox 93+, Safari 16+
- ~85% global support (2025)
**File Size Comparison** (1920x1080 image):
```
JPEG (quality 85): 180 KB
WebP (quality 85): 120 KB
AVIF (quality 85): 75 KB (58% smaller than JPEG)
```
### Format Selection Strategy
```typescript
// Cloudflare Images automatically selects best format
const imageUrl = `https://images.yourdomain.com/${imageId}/public?format=auto`;
// Browser receives:
// - AVIF if browser supports it
// - WebP if browser supports it
// - JPEG/PNG fallback
```
---
## Quality vs Size Tradeoffs
### Quality Settings
**Quality 100** (Original):
- File size: Largest
- Quality: Maximum
- Use: Archival, professional photography
**Quality 90** (High):
- File size: Large
- Quality: Excellent
- Use: Hero images, product photos
**Quality 85** (Recommended):
- File size: Medium
- Quality: Very good (imperceptible difference from 90)
- Use: Most web images
**Quality 75** (Medium):
- File size: Small
- Quality: Good
- Use: Thumbnails, previews
**Quality 60** (Low):
- File size: Very small
- Quality: Acceptable
- Use: Tiny thumbnails, previews
### Size Comparison Chart
**1920x1080 JPEG**:
```
Quality 100: 450 KB (baseline)
Quality 90: 180 KB (60% smaller)
Quality 85: 140 KB (69% smaller) ← Recommended
Quality 75: 95 KB (79% smaller)
Quality 60: 60 KB (87% smaller)
```
### Visual Quality Assessment
**Quality 85 vs 90**: Virtually indistinguishable to human eye
**Quality 75 vs 85**: Slight difference in fine details
**Quality 60 vs 75**: Noticeable compression artifacts
**Recommendation**: Use quality 85 for most web images. Human eye cannot perceive difference from quality 90, but file size is 22% smaller.
---
## Performance Impact
### Page Load Time Reduction
**Example Website** (10 images, 4G connection):
**Without Polish**:
```
Total image size: 8.5 MB
Load time: 12.3 seconds
```
**With Lossy Polish + WebP**:
```
Total image size: 3.2 MB (62% reduction)
Load time: 4.6 seconds (63% faster)
```
### Bandwidth Savings
**Monthly savings** (1M pageviews, 10 images/page, avg 250 KB/image):
**Without Polish**:
```
Bandwidth: 2.5 TB/month
Cost: ~$50-75/month
```
**With Polish**:
```
Bandwidth: 0.95 TB/month (62% reduction)
Cost: ~$19-28/month
Savings: ~$31-47/month
```
---
## Metadata Handling
### EXIF Data Removal
Polish removes EXIF metadata by default:
**Removed**:
- Camera make/model
- GPS location
- Timestamp
- Camera settings
- Copyright info
**Benefits**:
- Privacy protection (GPS data)
- Smaller file sizes (5-15% reduction)
**Preserve Metadata**:
If you need to keep EXIF data:
```html
<!-- Don't use Polish, use origin image -->
<img src="https://origin.yourdomain.com/photo.jpg" />
<!-- OR use Cloudflare Images with metadata=keep -->
<img src="/cdn-cgi/image/metadata=keep/photo.jpg" />
```
---
## Cache and Polish
### How Polish Works with Cache
1. **First Request**:
- Image fetched from origin
- Polish applied
- Compressed image cached at edge
2. **Subsequent Requests**:
- Compressed image served from cache
- No re-compression needed
### Cache Purging
**Purge specific image**:
```bash
curl --request POST \
https://api.cloudflare.com/client/v4/zones/{zone_id}/purge_cache \
--header "Authorization: Bearer <API_TOKEN>" \
--data '{"files": ["https://yourdomain.com/photo.jpg"]}'
```
**After purge**: Next request will re-apply Polish
---
## Troubleshooting
### Polish Not Working
**Check**:
1. Domain is proxied (orange cloud) in DNS
2. Polish is enabled in Speed settings
3. Image is cacheable (proper Cache-Control headers)
4. Image format is supported (JPEG, PNG, GIF, WebP)
5. Image size is under 100 MB
**Test**:
```bash
# Check response headers
curl -I https://yourdomain.com/image.jpg
# Look for:
CF-BGJ: imgq:85
CF-Polished: qual=85, status=success
```
### WebP Not Served
**Common Causes**:
- Browser doesn't support WebP (check User-Agent)
- WebP checkbox not enabled in Polish settings
- Image already in WebP format
**Test**:
```bash
# Request with WebP support
curl -I https://yourdomain.com/image.jpg \
-H "Accept: image/webp"
# Should return WebP image
Content-Type: image/webp
```
### Quality Issues
**If images look degraded**:
- Switch from Lossy to Lossless mode
- Increase quality parameter in Cloudflare Images
- Provide higher-quality origin images
---
## Best Practices
### 1. Use Lossy for Web Content
```
For 95% of web images, lossy mode is ideal
Visual quality remains excellent
File sizes reduced 35-65%
```
### 2. Enable WebP Conversion
```
Always enable WebP checkbox
25-35% additional savings
Wide browser support
Automatic fallback
```
### 3. Optimize Origin Images
```
Don't upload 10 MB photos
Pre-resize to reasonable dimensions
Use JPEG for photos, PNG for graphics
Let Polish handle final optimization
```
### 4. Test Visual Quality
```
Before:
View original image
After:
Enable Polish, clear cache, reload
Compare visual quality
Adjust settings if needed
```
---
## Combining Polish with Cloudflare Images
### Hybrid Strategy
**For new content**:
```typescript
// Use Cloudflare Images with format=auto
<img src="https://images.yourdomain.com/id/public?format=auto&quality=85" />
```
**For existing content**:
```typescript
// Use Polish for origin images
<img src="https://yourdomain.com/old-images/photo.jpg" />
// Automatically optimized by Polish
```
---
## Cost Optimization
Polish is **included free** with Cloudflare plans:
**Free Plan**: Lossless only
**Pro Plan**: Lossless + Lossy
**Business Plan**: Lossless + Lossy + WebP
**Enterprise Plan**: All features + AVIF
**No additional cost** for:
- Bandwidth savings
- Format conversion
- Compression
---
## Related References
- **Transformations**: See `references/transformation-options.md`
- **Format Optimization**: See `references/format-optimization.md`
- **Custom Domains**: See `references/custom-domains.md`
---
## Official Documentation
- **Polish**: https://developers.cloudflare.com/speed/optimization/images/polish/
- **Compression**: https://developers.cloudflare.com/speed/optimization/content/
references/responsive-images-patterns.md
# Responsive Images Patterns
Complete guide to serving optimal images for different devices and screen sizes.
---
## srcset with Named Variants
Best for consistent, predefined sizes.
```html
<img
srcset="
https://imagedelivery.net/HASH/ID/mobile 480w,
https://imagedelivery.net/HASH/ID/tablet 768w,
https://imagedelivery.net/HASH/ID/desktop 1920w
"
sizes="(max-width: 480px) 480px, (max-width: 768px) 768px, 1920px"
src="https://imagedelivery.net/HASH/ID/desktop"
alt="Responsive image"
loading="lazy"
/>
```
**Variants to create**:
- `mobile`: width=480, fit=scale-down
- `tablet`: width=768, fit=scale-down
- `desktop`: width=1920, fit=scale-down
---
## srcset with Flexible Variants
Best for dynamic sizing (public images only).
```html
<img
srcset="
https://imagedelivery.net/HASH/ID/w=480,f=auto 480w,
https://imagedelivery.net/HASH/ID/w=768,f=auto 768w,
https://imagedelivery.net/HASH/ID/w=1920,f=auto 1920w
"
sizes="(max-width: 480px) 480px, (max-width: 768px) 768px, 1920px"
src="https://imagedelivery.net/HASH/ID/w=1920,f=auto"
alt="Responsive image"
loading="lazy"
/>
```
---
## Art Direction (Different Crops)
Serve different image crops for mobile vs desktop.
```html
<picture>
<!-- Mobile: Square crop -->
<source
media="(max-width: 767px)"
srcset="https://imagedelivery.net/HASH/ID/mobile-square"
/>
<!-- Desktop: Wide crop -->
<source
media="(min-width: 768px)"
srcset="https://imagedelivery.net/HASH/ID/desktop-wide"
/>
<!-- Fallback -->
<img
src="https://imagedelivery.net/HASH/ID/desktop-wide"
alt="Art directed image"
loading="lazy"
/>
</picture>
```
**Variants to create**:
- `mobile-square`: width=480, height=480, fit=cover
- `desktop-wide`: width=1920, height=1080, fit=cover
---
## High-DPI (Retina) Displays
Serve 2x images for high-resolution screens.
```html
<img
srcset="
https://imagedelivery.net/HASH/ID/w=400,dpr=1,f=auto 1x,
https://imagedelivery.net/HASH/ID/w=400,dpr=2,f=auto 2x
"
src="https://imagedelivery.net/HASH/ID/w=400,f=auto"
alt="Retina-ready image"
/>
```
---
## Blur Placeholder (LQIP)
Load tiny blurred placeholder first, then swap to full image.
```html
<img
id="lqip-image"
src="https://imagedelivery.net/HASH/ID/w=50,q=10,blur=20,f=webp"
data-src="https://imagedelivery.net/HASH/ID/w=1920,f=auto"
alt="Image with LQIP"
style="filter: blur(10px); transition: filter 0.3s;"
/>
<script>
const img = document.getElementById('lqip-image');
const fullSrc = img.getAttribute('data-src');
const fullImg = new Image();
fullImg.src = fullSrc;
fullImg.onload = () => {
img.src = fullSrc;
img.style.filter = 'blur(0)';
};
</script>
```
---
## Lazy Loading
Defer loading below-the-fold images.
```html
<!-- Native lazy loading (modern browsers) -->
<img src="..." loading="lazy" alt="..." />
<!-- With Intersection Observer (better control) -->
<img
class="lazy"
data-src="https://imagedelivery.net/HASH/ID/w=800,f=auto"
alt="Lazy loaded image"
/>
<script>
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src;
img.classList.remove('lazy');
observer.unobserve(img);
}
});
});
document.querySelectorAll('img.lazy').forEach(img => observer.observe(img));
</script>
```
---
## URL Transformations (/cdn-cgi/image/)
Transform ANY publicly accessible image (not just Cloudflare Images storage).
```html
<img
srcset="
/cdn-cgi/image/width=480,format=auto/uploads/photo.jpg 480w,
/cdn-cgi/image/width=768,format=auto/uploads/photo.jpg 768w,
/cdn-cgi/image/width=1920,format=auto/uploads/photo.jpg 1920w
"
sizes="(max-width: 480px) 480px, (max-width: 768px) 768px, 1920px"
src="/cdn-cgi/image/width=1920,format=auto/uploads/photo.jpg"
alt="Transformed origin image"
loading="lazy"
/>
```
---
## Recommended Breakpoints
```javascript
const breakpoints = {
mobile: 480, // Small phones
tablet: 768, // Tablets
desktop: 1024, // Laptops
wide: 1920, // Desktops
ultrawide: 2560 // Large displays
};
```
**sizes attribute**:
```html
sizes="
(max-width: 480px) 480px,
(max-width: 768px) 768px,
(max-width: 1024px) 1024px,
(max-width: 1920px) 1920px,
2560px
"
```
---
## Complete Example
```html
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
img { max-width: 100%; height: auto; display: block; }
</style>
</head>
<body>
<!-- Hero image with art direction -->
<picture>
<source
media="(max-width: 767px)"
srcset="https://imagedelivery.net/HASH/ID/w=480,h=480,fit=cover,f=auto"
/>
<source
media="(min-width: 768px)"
srcset="https://imagedelivery.net/HASH/ID/w=1920,h=1080,fit=cover,f=auto"
/>
<img
src="https://imagedelivery.net/HASH/ID/w=1920,h=1080,fit=cover,f=auto"
alt="Hero image"
/>
</picture>
<!-- Responsive gallery images -->
<img
srcset="
https://imagedelivery.net/HASH/ID/w=480,f=auto 480w,
https://imagedelivery.net/HASH/ID/w=768,f=auto 768w,
https://imagedelivery.net/HASH/ID/w=1024,f=auto 1024w
"
sizes="
(max-width: 480px) 100vw,
(max-width: 768px) 50vw,
33vw
"
src="https://imagedelivery.net/HASH/ID/w=1024,f=auto"
alt="Gallery image"
loading="lazy"
/>
</body>
</html>
```
---
## Best Practices
1. **Always use format=auto**: Optimal WebP/AVIF delivery
2. **Add loading="lazy"**: Below-the-fold images
3. **Match sizes to CSS layout**: Use `sizes` attribute correctly
4. **Provide descriptive alt text**: Accessibility
5. **Use LQIP for perceived performance**: Better UX
6. **Named variants for private**: Signed URLs compatible
7. **Flexible variants for public**: Dynamic sizing
8. **Limit srcset to 3-5 sizes**: Balance performance vs flexibility
---
## Official Documentation
- **Responsive Images (MDN)**: https://developer.mozilla.org/en-US/docs/Learn/HTML/Multimedia_and_embedding/Responsive_images
- **Cloudflare Images**: https://developers.cloudflare.com/images/
references/setup-guide.md
# Cloudflare Images Complete Setup Guide
Quick setup walkthrough for Cloudflare Images API and Transformations.
---
## Step 1: Enable Cloudflare Images
1. Log into Cloudflare Dashboard
2. Navigate to **Images**
3. Click **Enable**
4. Note your **Account ID** and **Account Hash**
---
## Step 2: Create API Token
1. Go to **API Tokens** in dashboard
2. Click **Create Token**
3. Grant **Cloudflare Images: Edit** permission
4. Save token securely (it won't be shown again)
**Required permissions:**
- Cloudflare Images: Edit
---
## Step 3: Upload First Image
### Via cURL:
```bash
curl --request POST \
--url https://api.cloudflare.com/client/v4/accounts/<ACCOUNT_ID>/images/v1 \
--header 'Authorization: Bearer <API_TOKEN>' \
--header 'Content-Type: multipart/form-data' \
--form 'file=@./photo.jpg'
```
### Via TypeScript:
```typescript
const formData = new FormData();
formData.append('file', file);
const response = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${ACCOUNT_ID}/images/v1`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${API_TOKEN}`
},
body: formData
}
);
const { result } = await response.json();
console.log('Image ID:', result.id);
console.log('URL:', result.variants[0]);
```
---
## Step 4: Serve Images
**Public images:**
```html
<img src="https://imagedelivery.net/<ACCOUNT_HASH>/<IMAGE_ID>/public" />
```
**With transformations:**
```html
<img src="https://imagedelivery.net/<ACCOUNT_HASH>/<IMAGE_ID>/width=800" />
```
---
## Step 5: Enable Image Transformations
For transforming any image (not just uploaded ones):
1. Dashboard → **Images** → **Transformations**
2. Select your zone (domain)
3. Click **Enable for zone**
Now you can transform any image on your domain:
```html
<img src="/cdn-cgi/image/width=800,quality=85/uploads/photo.jpg" />
```
---
## Step 6: Create Variants (Optional)
Variants are predefined transformations:
1. Dashboard → **Images** → **Variants**
2. Click **Create variant**
3. Name: `thumbnail` (or any name)
4. Configure: width=200, height=200, fit=cover
5. Save
Use in URLs:
```html
<img src="https://imagedelivery.net/<ACCOUNT_HASH>/<IMAGE_ID>/thumbnail" />
```
---
## Step 7: Direct Creator Upload (User Uploads)
For allowing users to upload without exposing API keys:
### Backend:
```typescript
const response = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${ACCOUNT_ID}/images/v2/direct_upload`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${API_TOKEN}`
}
}
);
const { result } = await response.json();
const uploadURL = result.uploadURL;
// Send uploadURL to frontend
return new Response(JSON.stringify({ uploadURL }));
```
### Frontend:
```typescript
const formData = new FormData();
formData.append('file', file);
await fetch(uploadURL, {
method: 'POST',
body: formData
});
```
**Load `references/direct-upload-complete-workflow.md` for complete implementation.**
---
## Production Checklist
- [ ] API token created and stored securely
- [ ] Account ID and Account Hash documented
- [ ] Direct upload CORS configured (if using from browser)
- [ ] Variants created for common sizes
- [ ] Image Transformations enabled for zones (if needed)
- [ ] Signed URLs configured for private images (if needed)
- [ ] Error handling implemented
- [ ] Rate limiting configured
- [ ] Batch upload logic implemented (if needed)
- [ ] Responsive images with srcset configured
---
## Official Documentation
- **Images Overview**: https://developers.cloudflare.com/images/
- **Upload API**: https://developers.cloudflare.com/images/upload-images/
- **Transformations**: https://developers.cloudflare.com/images/transform-images/
- **Direct Creator Upload**: https://developers.cloudflare.com/images/upload-images/direct-creator-upload/
references/signed-urls-guide.md
# Signed URLs Guide
Complete guide to generating signed URLs for private images using HMAC-SHA256.
---
## What Are Signed URLs?
Time-limited URLs for serving private images securely.
**Format**:
```
https://imagedelivery.net/<HASH>/<ID>/<VARIANT>?exp=<EXPIRY>&sig=<SIGNATURE>
```
**Use cases**:
- User profile photos (private until shared)
- Paid content (time-limited access)
- Temporary downloads
- Secure image delivery
---
## Requirements
1. **Upload with signed URLs enabled**:
```javascript
await uploadImage(file, {
requireSignedURLs: true // Image requires signed URL
});
```
2. **Get signing key**:
Dashboard → Images → Keys → Generate key
3. **Use named variants only**:
Flexible variants NOT compatible with signed URLs.
---
## Signature Algorithm (HMAC-SHA256)
### String to Sign
```
{imageId}{variant}{expiry}
```
**Example**:
```
Image ID: abc123
Variant: public
Expiry: 1735228800
String to sign: abc123public1735228800
```
### Generate Signature
**Workers** (recommended):
```typescript
async function generateSignature(
imageId: string,
variant: string,
expiry: number,
signingKey: string
): Promise<string> {
const stringToSign = `${imageId}${variant}${expiry}`;
const encoder = new TextEncoder();
const keyData = encoder.encode(signingKey);
const messageData = encoder.encode(stringToSign);
const cryptoKey = await crypto.subtle.importKey(
'raw',
keyData,
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const signature = await crypto.subtle.sign('HMAC', cryptoKey, messageData);
// Convert to hex string
return Array.from(new Uint8Array(signature))
.map(b => b.toString(16).padStart(2, '0'))
.join('');
}
```
**Node.js**:
```javascript
const crypto = require('crypto');
function generateSignature(imageId, variant, expiry, signingKey) {
const stringToSign = `${imageId}${variant}${expiry}`;
return crypto
.createHmac('sha256', signingKey)
.update(stringToSign)
.digest('hex');
}
```
### Build Signed URL
```typescript
async function generateSignedURL(
imageId: string,
variant: string,
expirySeconds: number,
accountHash: string,
signingKey: string
): Promise<string> {
const expiry = Math.floor(Date.now() / 1000) + expirySeconds;
const sig = await generateSignature(imageId, variant, expiry, signingKey);
return `https://imagedelivery.net/${accountHash}/${imageId}/${variant}?exp=${expiry}&sig=${sig}`;
}
```
---
## Expiry Timestamp
**Unix timestamp** (seconds since epoch):
```typescript
const now = Math.floor(Date.now() / 1000);
const oneHour = 60 * 60;
const expiry = now + oneHour; // 1 hour from now
```
**From specific date**:
```typescript
const expiryDate = new Date('2025-10-27T18:00:00Z');
const expiry = Math.floor(expiryDate.getTime() / 1000);
```
**Common presets**:
```typescript
const expiryPresets = {
fiveMinutes: 5 * 60,
fifteenMinutes: 15 * 60,
oneHour: 60 * 60,
oneDay: 24 * 60 * 60,
oneWeek: 7 * 24 * 60 * 60
};
```
---
## Complete Example (Workers)
```typescript
interface Env {
IMAGES_ACCOUNT_HASH: string;
IMAGES_SIGNING_KEY: string;
}
async function generateSignedURL(
imageId: string,
variant: string,
expirySeconds: number,
env: Env
): Promise<string> {
const now = Math.floor(Date.now() / 1000);
const expiry = now + expirySeconds;
const stringToSign = `${imageId}${variant}${expiry}`;
const encoder = new TextEncoder();
const keyData = encoder.encode(env.IMAGES_SIGNING_KEY);
const messageData = encoder.encode(stringToSign);
const cryptoKey = await crypto.subtle.importKey(
'raw',
keyData,
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const signature = await crypto.subtle.sign('HMAC', cryptoKey, messageData);
const sig = Array.from(new Uint8Array(signature))
.map(b => b.toString(16).padStart(2, '0'))
.join('');
return `https://imagedelivery.net/${env.IMAGES_ACCOUNT_HASH}/${imageId}/${variant}?exp=${expiry}&sig=${sig}`;
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
// Generate signed URL valid for 1 hour
const signedURL = await generateSignedURL(
'image-id',
'public',
3600,
env
);
return Response.json({ signedURL });
}
};
```
---
## Multiple Variants
Generate signed URLs for multiple variants at once:
```typescript
async function generateSignedURLsForVariants(
imageId: string,
variants: string[],
expirySeconds: number,
env: Env
): Promise<Record<string, string>> {
const urls: Record<string, string> = {};
for (const variant of variants) {
urls[variant] = await generateSignedURL(imageId, variant, expirySeconds, env);
}
return urls;
}
// Usage
const urls = await generateSignedURLsForVariants(
'image-id',
['thumbnail', 'medium', 'large'],
3600,
env
);
// {
// thumbnail: 'https://imagedelivery.net/.../thumbnail?exp=...&sig=...',
// medium: 'https://imagedelivery.net/.../medium?exp=...&sig=...',
// large: 'https://imagedelivery.net/.../large?exp=...&sig=...'
// }
```
---
## Verification (Cloudflare handles this)
For reference, here's how verification works:
```typescript
async function verifySignature(
imageId: string,
variant: string,
expiry: number,
providedSig: string,
signingKey: string
): Promise<boolean> {
// Check if expired
const now = Math.floor(Date.now() / 1000);
if (expiry < now) {
return false; // Expired
}
// Generate expected signature
const expectedSig = await generateSignature(imageId, variant, expiry, signingKey);
return expectedSig === providedSig;
}
```
---
## Common Issues
### 1. Signed URL returns 403
**Causes**:
- Image not uploaded with `requireSignedURLs=true`
- Signature incorrect (wrong signing key)
- URL expired
- Using flexible variants (not supported)
**Solutions**:
- Verify image requires signed URLs
- Check signing key matches dashboard
- Ensure expiry in future
- Use named variants only
### 2. Signature doesn't match
**Causes**:
- Wrong signing key
- Incorrect string-to-sign format
- Timestamp precision (must be seconds, not milliseconds)
**Solutions**:
```typescript
// ✅ CORRECT - Seconds
const expiry = Math.floor(Date.now() / 1000);
// ❌ WRONG - Milliseconds
const expiry = Date.now();
```
### 3. Cannot use with flexible variants
**Error**: 403 Forbidden when using flexible variants with signed URLs
**Solution**: Use named variants for private images
```typescript
// ✅ CORRECT
const url = await generateSignedURL('id', 'thumbnail', 3600, env);
// ❌ WRONG
const url = `https://imagedelivery.net/${hash}/${id}/w=300?exp=${exp}&sig=${sig}`;
```
---
## Security Best Practices
1. **Keep signing key secret**: Never expose in client-side code
2. **Generate on backend**: Frontend requests signed URL from backend
3. **Short expiry for sensitive content**: 5-15 minutes for temporary access
4. **Longer expiry for user content**: 1-24 hours for profile photos
5. **Rotate keys periodically**: Dashboard → Images → Keys → Regenerate
6. **Log suspicious activity**: Monitor for signature mismatches
---
## Example Use Cases
### Profile Photos (24-hour expiry)
```typescript
const profileURL = await generateSignedURL('user-123', 'avatar', 24 * 60 * 60, env);
```
### Temporary Download (5 minutes)
```typescript
const downloadURL = await generateSignedURL('doc-456', 'large', 5 * 60, env);
```
### Paid Content (1-week subscription)
```typescript
const contentURL = await generateSignedURL('premium-789', 'medium', 7 * 24 * 60 * 60, env);
```
---
## Official Documentation
- **Serve Private Images**: https://developers.cloudflare.com/images/manage-images/serve-images/serve-private-images/
references/sourcing-kit.md
# Sourcing Kit for Cloudflare Images
Guide to integrating external image sources, managing credentials, and migrating from other CDNs to Cloudflare Images.
---
## External Source Integration
### Upload via URL
Cloudflare Images can ingest images from external URLs:
```bash
curl --request POST \
https://api.cloudflare.com/client/v4/accounts/{account_id}/images/v1 \
--header "Authorization: Bearer <API_TOKEN>" \
--form "url=https://example.com/image.jpg"
```
**Supported Sources**:
- ✅ Public URLs (HTTP/HTTPS)
- ✅ Cloud storage (S3, Google Cloud Storage, Azure Blob)
- ✅ Other CDNs (Cloudinary, Imgix, etc.)
- ❌ Password-protected URLs
- ❌ URLs requiring authentication
---
## Migrating from Other CDNs
### Strategy 1: Bulk Import via URL
**From Cloudinary**:
```typescript
// Fetch all images from Cloudinary
const cloudinaryImages = await fetchCloudinaryImages();
// Import to Cloudflare Images
for (const image of cloudinaryImages) {
const cloudinaryUrl = `https://res.cloudinary.com/${cloud_name}/image/upload/${image.public_id}.jpg`;
const formData = new FormData();
formData.append('url', cloudinaryUrl);
formData.append('metadata', JSON.stringify({
source: 'cloudinary',
originalId: image.public_id
}));
const response = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${accountId}/images/v1`,
{
method: 'POST',
headers: { 'Authorization': `Bearer ${apiToken}` },
body: formData
}
);
const result = await response.json();
console.log(`Migrated: ${image.public_id} → ${result.result.id}`);
}
```
**From Imgix**:
```typescript
async function migrateFromImgix(imgixUrl: string) {
const formData = new FormData();
formData.append('url', imgixUrl);
const response = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${accountId}/images/v1`,
{
method: 'POST',
headers: { 'Authorization': `Bearer ${apiToken}` },
body: formData
}
);
return response.json();
}
// Usage
await migrateFromImgix('https://your-domain.imgix.net/photo.jpg');
```
### Strategy 2: Download and Re-Upload
**For S3 buckets**:
```typescript
import { S3Client, GetObjectCommand, ListObjectsV2Command } from '@aws-sdk/client-s3';
const s3 = new S3Client({ region: 'us-east-1' });
async function migrateFromS3(bucket: string, prefix: string) {
// List objects
const listCommand = new ListObjectsV2Command({
Bucket: bucket,
Prefix: prefix
});
const { Contents } = await s3.send(listCommand);
for (const object of Contents || []) {
// Download from S3
const getCommand = new GetObjectCommand({
Bucket: bucket,
Key: object.Key
});
const { Body } = await s3.send(getCommand);
const blob = await Body?.transformToByteArray();
if (!blob) continue;
// Upload to Cloudflare Images
const formData = new FormData();
formData.append('file', new Blob([blob]), object.Key || 'image.jpg');
const response = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${accountId}/images/v1`,
{
method: 'POST',
headers: { 'Authorization': `Bearer ${apiToken}` },
body: formData
}
);
const result = await response.json();
console.log(`Migrated: ${object.Key} → ${result.result.id}`);
}
}
```
---
## Credentials Management
### API Token Best Practices
**1. Scoped Tokens**:
```
Create token with minimum required permissions:
- Cloudflare Images: Edit (upload, delete)
- Cloudflare Images: Read (list, fetch metadata)
```
**2. Environment Variables**:
```typescript
// .env
CF_ACCOUNT_ID=your_account_id
CF_API_TOKEN=your_api_token_images_edit
CF_ACCOUNT_HASH=your_account_hash
// Never commit to git
// Add to .gitignore:
.env
.env.local
.env.*.local
```
**3. Token Rotation**:
```bash
# Create new token
# Update environment variables
# Revoke old token
# Script to rotate token
./scripts/rotate-api-token.sh
```
**4. Secure Storage**:
**For Cloudflare Workers**:
```bash
# Use secrets (not environment variables)
wrangler secret put CF_API_TOKEN
```
**For Serverless Functions**:
```bash
# Vercel
vercel env add CF_API_TOKEN production
# Netlify
netlify env:set CF_API_TOKEN value
```
---
## Multi-Source Integration
### Aggregate Images from Multiple Sources
```typescript
interface ImageSource {
type: 'url' | 's3' | 'cloudinary' | 'local';
location: string;
credentials?: Record<string, string>;
}
async function importFromSource(source: ImageSource) {
switch (source.type) {
case 'url':
return importFromURL(source.location);
case 's3':
return importFromS3(
source.location,
source.credentials?.accessKeyId!,
source.credentials?.secretAccessKey!
);
case 'cloudinary':
return importFromCloudinary(
source.location,
source.credentials?.cloudName!,
source.credentials?.apiKey!
);
case 'local':
return importFromLocal(source.location);
default:
throw new Error(`Unsupported source type: ${source.type}`);
}
}
// Usage
const sources: ImageSource[] = [
{
type: 'url',
location: 'https://example.com/photos/image1.jpg'
},
{
type: 's3',
location: 's3://my-bucket/images/',
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!
}
},
{
type: 'cloudinary',
location: 'my-image-id',
credentials: {
cloudName: process.env.CLOUDINARY_CLOUD_NAME!,
apiKey: process.env.CLOUDINARY_API_KEY!
}
}
];
for (const source of sources) {
await importFromSource(source);
}
```
---
## Webhook Integration for External Updates
### Listen for External Source Changes
**When external source updates**:
```typescript
// Webhook handler for external CDN
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const webhook = await request.json<{
event: 'image.updated' | 'image.deleted';
imageUrl: string;
imageId: string;
}>();
if (webhook.event === 'image.updated') {
// Re-import updated image from external source
await reimportImage(webhook.imageUrl, env);
}
if (webhook.event === 'image.deleted') {
// Delete from Cloudflare Images
await deleteImage(webhook.imageId, env);
}
return new Response('Webhook processed', { status: 200 });
}
};
async function reimportImage(url: string, env: Env) {
const formData = new FormData();
formData.append('url', url);
await fetch(
`https://api.cloudflare.com/client/v4/accounts/${env.CF_ACCOUNT_ID}/images/v1`,
{
method: 'POST',
headers: { 'Authorization': `Bearer ${env.CF_API_TOKEN}` },
body: formData
}
);
}
```
---
## Batch Import Script
### Complete Migration Script
**scripts/migrate-images.ts**:
```typescript
import { readdir, readFile } from 'fs/promises';
import { join } from 'path';
interface MigrationConfig {
accountId: string;
apiToken: string;
sourceDir: string;
batchSize: number;
}
async function migrateImages(config: MigrationConfig) {
const files = await readdir(config.sourceDir);
const imageFiles = files.filter(f =>
/\.(jpg|jpeg|png|gif|webp)$/i.test(f)
);
console.log(`Found ${imageFiles.length} images to migrate`);
let processed = 0;
let errors = 0;
// Process in batches
for (let i = 0; i < imageFiles.length; i += config.batchSize) {
const batch = imageFiles.slice(i, i + config.batchSize);
const uploadPromises = batch.map(async (file) => {
try {
const filePath = join(config.sourceDir, file);
const fileBuffer = await readFile(filePath);
const formData = new FormData();
formData.append('file', new Blob([fileBuffer]), file);
const response = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${config.accountId}/images/v1`,
{
method: 'POST',
headers: { 'Authorization': `Bearer ${config.apiToken}` },
body: formData
}
);
const result = await response.json();
if (result.success) {
processed++;
console.log(`✓ Uploaded: ${file} → ${result.result.id}`);
} else {
errors++;
console.error(`✗ Failed: ${file}`, result.errors);
}
} catch (error) {
errors++;
console.error(`✗ Error uploading ${file}:`, error);
}
});
await Promise.all(uploadPromises);
// Rate limiting delay
await new Promise(resolve => setTimeout(resolve, 1000));
}
console.log(`\nMigration complete:`);
console.log(` Processed: ${processed}`);
console.log(` Errors: ${errors}`);
console.log(` Total: ${imageFiles.length}`);
}
// Usage
await migrateImages({
accountId: process.env.CF_ACCOUNT_ID!,
apiToken: process.env.CF_API_TOKEN!,
sourceDir: './images-to-migrate',
batchSize: 10
});
```
**Run**:
```bash
tsx scripts/migrate-images.ts
```
---
## URL Mapping for Migration
### Maintain URL Compatibility
**Create mapping table**:
```typescript
// Database schema
interface ImageMapping {
oldUrl: string; // https://old-cdn.com/abc/image.jpg
cloudflareId: string; // Cloudflare Images ID
cloudflareUrl: string; // https://imagedelivery.net/.../public
}
// During migration
await db.imageMappings.create({
data: {
oldUrl: 'https://old-cdn.com/images/product-123.jpg',
cloudflareId: '2cdc28f0-017a-49c4-9ed7-87056c83901',
cloudflareUrl: `https://imagedelivery.net/${accountHash}/2cdc28f0.../public`
}
});
```
**Redirect old URLs**:
```typescript
// Cloudflare Worker
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
// Check if this is an old CDN URL pattern
if (url.pathname.startsWith('/old-cdn/')) {
const oldUrl = url.toString();
// Lookup mapping
const mapping = await env.DB.prepare(
'SELECT cloudflareUrl FROM imageMappings WHERE oldUrl = ?'
).bind(oldUrl).first<{ cloudflareUrl: string }>();
if (mapping) {
return Response.redirect(mapping.cloudflareUrl, 301);
}
}
return fetch(request);
}
};
```
---
## Cost Optimization During Migration
### Minimize Bandwidth Costs
**1. Use Direct URL Import** (No download to your server):
```typescript
// ✅ EFFICIENT: Cloudflare fetches directly
formData.append('url', externalImageUrl);
// ❌ INEFFICIENT: Download to server first
const response = await fetch(externalImageUrl);
const blob = await response.blob();
formData.append('file', blob);
```
**2. Batch Operations**:
```typescript
// Process 100 images at a time
const batchSize = 100;
for (let i = 0; i < urls.length; i += batchSize) {
const batch = urls.slice(i, i + batchSize);
await Promise.all(batch.map(url => importImage(url)));
// Rate limit: 1 second between batches
await new Promise(resolve => setTimeout(resolve, 1000));
}
```
**3. Deduplicate Before Import**:
```typescript
// Check if image already exists
const existingImage = await db.images.findFirst({
where: { originalUrl: externalUrl }
});
if (!existingImage) {
// Import only if not already migrated
await importImage(externalUrl);
}
```
---
## Error Handling
### Retry Logic for Failed Imports
```typescript
async function importWithRetry(
url: string,
maxRetries = 3
): Promise<any> {
let lastError: Error;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const formData = new FormData();
formData.append('url', url);
const response = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${accountId}/images/v1`,
{
method: 'POST',
headers: { 'Authorization': `Bearer ${apiToken}` },
body: formData
}
);
const result = await response.json();
if (result.success) {
return result;
}
throw new Error(JSON.stringify(result.errors));
} catch (error) {
lastError = error as Error;
console.error(`Attempt ${attempt} failed:`, error);
if (attempt < maxRetries) {
// Exponential backoff
const delay = Math.pow(2, attempt) * 1000;
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
throw lastError!;
}
```
---
## Progress Tracking
### Monitor Migration Progress
```typescript
interface MigrationProgress {
total: number;
processed: number;
succeeded: number;
failed: number;
startTime: Date;
estimatedCompletion: Date | null;
}
class MigrationTracker {
private progress: MigrationProgress;
constructor(total: number) {
this.progress = {
total,
processed: 0,
succeeded: 0,
failed: 0,
startTime: new Date(),
estimatedCompletion: null
};
}
recordSuccess() {
this.progress.processed++;
this.progress.succeeded++;
this.updateEstimate();
this.logProgress();
}
recordFailure() {
this.progress.processed++;
this.progress.failed++;
this.updateEstimate();
this.logProgress();
}
private updateEstimate() {
const elapsed = Date.now() - this.progress.startTime.getTime();
const rate = this.progress.processed / elapsed;
const remaining = this.progress.total - this.progress.processed;
const estimatedMs = remaining / rate;
this.progress.estimatedCompletion = new Date(Date.now() + estimatedMs);
}
private logProgress() {
const { processed, total, succeeded, failed, estimatedCompletion } = this.progress;
const percentage = ((processed / total) * 100).toFixed(1);
console.log(
`Progress: ${processed}/${total} (${percentage}%) | ✓ ${succeeded} | ✗ ${failed} | ETA: ${estimatedCompletion?.toLocaleTimeString()}`
);
}
}
// Usage
const tracker = new MigrationTracker(1000);
for (const url of imageUrls) {
try {
await importImage(url);
tracker.recordSuccess();
} catch (error) {
tracker.recordFailure();
}
}
```
---
## Best Practices
### 1. Test with Sample First
```typescript
// Test with 10 images before full migration
const testUrls = imageUrls.slice(0, 10);
for (const url of testUrls) {
await importImage(url);
}
// Verify results, then proceed with full migration
```
### 2. Maintain Source Mapping
```typescript
// Always store original source
await db.images.create({
data: {
cloudflareId: result.result.id,
originalSource: 'cloudinary',
originalId: cloudinaryPublicId,
importedAt: new Date()
}
});
```
### 3. Implement Rollback Strategy
```typescript
// Keep old CDN active during transition
// Test Cloudflare Images thoroughly
// Gradually switch traffic
// Decommission old CDN only after validation
```
---
## Related References
- **Upload API**: See `references/api-reference.md`
- **Direct Creator Upload**: See `references/direct-upload-complete-workflow.md`
- **Custom Domains**: See `references/custom-domains.md`
---
## Official Documentation
- **Upload Images**: https://developers.cloudflare.com/images/upload-images/
- **Upload via URL**: https://developers.cloudflare.com/images/upload-images/upload-via-url/
- **API Reference**: https://developers.cloudflare.com/api/resources/images/
references/top-errors.md
# Top Errors and Solutions
Complete troubleshooting guide for all documented Cloudflare Images errors.
---
## Direct Creator Upload Errors
### 1. CORS Error - Content-Type Not Allowed
**Error**:
```
Access to XMLHttpRequest blocked by CORS policy: Request header field content-type is not allowed by Access-Control-Allow-Headers
```
**Source**: [Cloudflare Community #345739](https://community.cloudflare.com/t/direct-image-upload-cors-error/345739), [#368114](https://community.cloudflare.com/t/cloudflare-images-direct-upload-cors-problem/368114)
**Why It Happens**:
Server CORS settings only allow `multipart/form-data` for Content-Type header.
**Solution**:
```javascript
// ✅ CORRECT
const formData = new FormData();
formData.append('file', fileInput.files[0]);
await fetch(uploadURL, {
method: 'POST',
body: formData // Browser sets multipart/form-data automatically
});
// ❌ WRONG
await fetch(uploadURL, {
headers: { 'Content-Type': 'application/json' }, // CORS error
body: JSON.stringify({ file: base64Image })
});
```
**Prevention**:
- Use FormData API
- Let browser set Content-Type header (don't set manually)
- Name field `file` (not `image` or other)
---
### 2. Error 5408 - Upload Timeout
**Error**: `Error 5408` after ~15 seconds
**Source**: [Cloudflare Community #571336](https://community.cloudflare.com/t/images-direct-creator-upload-error-5408/571336)
**Why It Happens**:
Cloudflare has 30-second request timeout. Slow uploads or large files exceed limit.
**Solution**:
```javascript
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
if (file.size > MAX_FILE_SIZE) {
alert('File too large. Please select an image under 10MB.');
return;
}
// Compress image before upload (optional)
async function compressImage(file) {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const img = await createImageBitmap(file);
const maxWidth = 4000;
const scale = Math.min(1, maxWidth / img.width);
canvas.width = img.width * scale;
canvas.height = img.height * scale;
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
return new Promise((resolve) => {
canvas.toBlob((blob) => resolve(blob), 'image/jpeg', 0.9);
});
}
```
**Prevention**:
- Limit file size (10MB max recommended)
- Compress images client-side if needed
- Show upload progress to user
- Handle timeout errors gracefully
---
### 3. Error 400 - Invalid File Parameter
**Error**: `400 Bad Request` with unhelpful message
**Source**: [Cloudflare Community #487629](https://community.cloudflare.com/t/direct-creator-upload-returning-400/487629)
**Why It Happens**:
File field must be named `file` (not `image`, `photo`, etc.).
**Solution**:
```javascript
// ✅ CORRECT
formData.append('file', imageFile);
// ❌ WRONG
formData.append('image', imageFile); // 400 error
formData.append('photo', imageFile); // 400 error
formData.append('upload', imageFile); // 400 error
```
**Prevention**:
- Always name the field `file`
- Check FormData contents before sending
---
### 4. CORS Preflight Failures
**Error**: Preflight OPTIONS request blocked
**Source**: [Cloudflare Community #306805](https://community.cloudflare.com/t/cors-error-when-using-direct-creator-upload/306805)
**Why It Happens**:
Calling `/direct_upload` API directly from browser (should be backend-only).
**Solution**:
```
CORRECT ARCHITECTURE:
Browser → POST /api/upload-url → Backend
↓
POST /direct_upload → Cloudflare API
↓
Backend ← Returns uploadURL ← Cloudflare API
↓
Browser receives uploadURL
↓
Browser → Uploads to uploadURL → Cloudflare (direct upload)
```
**Prevention**:
- Never expose API token to browser
- Generate upload URL on backend
- Return uploadURL to frontend
- Frontend uploads to uploadURL (not /direct_upload)
---
## Image Transformation Errors
### 5. Error 9401 - Invalid Arguments
**Error**: `Cf-Resized: err=9401` in response headers
**Source**: [Cloudflare Docs - Troubleshooting](https://developers.cloudflare.com/images/reference/troubleshooting/)
**Why It Happens**:
Missing required `cf.image` parameters or invalid values.
**Solution**:
```typescript
// ✅ CORRECT
fetch(imageURL, {
cf: {
image: {
width: 800,
quality: 85,
format: 'auto'
}
}
});
// ❌ WRONG
fetch(imageURL, {
cf: {
image: {
width: 'large', // Must be number
quality: 150, // Max 100
format: 'invalid' // Must be valid format
}
}
});
```
**Prevention**:
- Validate all parameters
- Use TypeScript for type checking
- Check official docs for valid ranges
---
### 6. Error 9402 - Image Too Large
**Error**: `Cf-Resized: err=9402`
**Source**: [Cloudflare Docs - Troubleshooting](https://developers.cloudflare.com/images/reference/troubleshooting/)
**Why It Happens**:
Image exceeds maximum area or download fails.
**Solution**:
```typescript
// Check image dimensions before transforming
const response = await fetch(imageURL, { method: 'HEAD' });
// Or fetch and check after
const img = await fetch(imageURL);
// Validate size
```
**Prevention**:
- Validate source image dimensions
- Max 100 megapixels (e.g., 10000x10000px)
- Use reasonable source images
---
### 7. Error 9403 - Request Loop
**Error**: `Cf-Resized: err=9403`
**Source**: [Cloudflare Docs - Troubleshooting](https://developers.cloudflare.com/images/reference/troubleshooting/)
**Why It Happens**:
Worker fetching its own URL or already-resized image.
**Solution**:
```typescript
// ✅ CORRECT - Fetch external origin
export default {
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
if (url.pathname.startsWith('/images/')) {
const imagePath = url.pathname.replace('/images/', '');
const originURL = `https://storage.example.com/${imagePath}`;
return fetch(originURL, {
cf: { image: { width: 800 } }
});
}
return new Response('Not found', { status: 404 });
}
};
// ❌ WRONG - Fetches worker's own URL (loop)
export default {
async fetch(request: Request): Promise<Response> {
return fetch(request, { // Fetches self
cf: { image: { width: 800 } }
});
}
};
```
**Prevention**:
- Always fetch external origin
- Don't transform already-transformed images
- Check URL routing logic
---
### 8. Error 9406/9419 - Invalid URL Format
**Error**: `Cf-Resized: err=9406` or `err=9419`
**Source**: [Cloudflare Docs - Troubleshooting](https://developers.cloudflare.com/images/reference/troubleshooting/)
**Why It Happens**:
Image URL uses HTTP (not HTTPS) or contains spaces/unescaped Unicode.
**Solution**:
```typescript
// ✅ CORRECT
const filename = "photo name.jpg";
const imageURL = `https://example.com/images/${encodeURIComponent(filename)}`;
// ❌ WRONG
const imageURL = "http://example.com/image.jpg"; // HTTP not allowed
const imageURL = "https://example.com/photo name.jpg"; // Space not encoded
```
**Prevention**:
- Always use HTTPS (HTTP not supported)
- URL-encode all paths with `encodeURIComponent()`
- No spaces or unescaped Unicode in URLs
---
### 9. Error 9412 - Non-Image Response
**Error**: `Cf-Resized: err=9412`
**Source**: [Cloudflare Docs - Troubleshooting](https://developers.cloudflare.com/images/reference/troubleshooting/)
**Why It Happens**:
Origin server returns HTML (e.g., 404 page) instead of image.
**Solution**:
```typescript
// Verify URL before transforming
const originResponse = await fetch(imageURL, { method: 'HEAD' });
const contentType = originResponse.headers.get('content-type');
if (!contentType?.startsWith('image/')) {
return new Response('Not an image', { status: 400 });
}
return fetch(imageURL, {
cf: { image: { width: 800 } }
});
```
**Prevention**:
- Verify origin returns image (check Content-Type)
- Handle 404s before transforming
- Validate image URLs
---
### 10. Error 9413 - Max Image Area Exceeded
**Error**: `Cf-Resized: err=9413`
**Source**: [Cloudflare Docs - Troubleshooting](https://developers.cloudflare.com/images/reference/troubleshooting/)
**Why It Happens**:
Source image exceeds 100 megapixels (e.g., 10000x10000px).
**Solution**:
```typescript
const MAX_MEGAPIXELS = 100;
if (width * height > MAX_MEGAPIXELS * 1_000_000) {
return new Response('Image too large', { status: 413 });
}
```
**Prevention**:
- Validate image dimensions before transforming
- Pre-process oversized images
- Reject images above threshold (100 megapixels)
---
## Configuration Errors
### 11. Flexible Variants + Signed URLs Incompatibility
**Error**: Flexible variants don't work with private images
**Source**: [Cloudflare Docs - Enable flexible variants](https://developers.cloudflare.com/images/manage-images/enable-flexible-variants/)
**Why It Happens**:
Flexible variants cannot be used with `requireSignedURLs=true`.
**Solution**:
```typescript
// ✅ CORRECT - Use named variants for private images
await uploadImage({
file: imageFile,
requireSignedURLs: true // Use named variants: /public, /avatar, etc.
});
// ❌ WRONG - Flexible variants don't support signed URLs
// Cannot use: /w=400,sharpen=3 with requireSignedURLs=true
```
**Prevention**:
- Use named variants for private images
- Use flexible variants for public images only
---
### 12. SVG Resizing Limitation
**Error**: SVG files don't resize via transformations
**Source**: [Cloudflare Docs - SVG files](https://developers.cloudflare.com/images/transform-images/#svg-files)
**Why It Happens**:
SVG is vector format (inherently scalable), resizing not applicable.
**Solution**:
```typescript
// SVGs can be served but not resized
// Use any variant name as placeholder
// https://imagedelivery.net/<HASH>/<SVG_ID>/public
// SVG will be served at original size regardless of variant settings
```
**Prevention**:
- Don't try to resize SVGs
- Serve SVGs as-is
- Use variants as placeholders
---
### 13. EXIF Metadata Stripped by Default
**Error**: GPS data, camera settings removed from uploaded JPEGs
**Source**: [Cloudflare Docs - Transform via URL](https://developers.cloudflare.com/images/transform-images/transform-via-url/#metadata)
**Why It Happens**:
Default behavior strips all metadata except copyright.
**Solution**:
```typescript
// Preserve metadata
fetch(imageURL, {
cf: {
image: {
width: 800,
metadata: 'keep' // Options: 'none', 'copyright', 'keep'
}
}
});
```
**Prevention**:
- Use `metadata=keep` if preservation needed
- Default `copyright` for JPEG
- Color profiles and EXIF rotation always applied
---
## General Troubleshooting
### Images not transforming
**Symptoms**: `/cdn-cgi/image/...` returns original or 404
**Solutions**:
1. Enable transformations: Dashboard → Images → Transformations → Enable
2. Verify zone proxied (orange cloud)
3. Check source image accessible
4. Wait 5-10 minutes for propagation
### Signed URLs returning 403
**Symptoms**: 403 Forbidden with signed URL
**Solutions**:
1. Verify image uploaded with `requireSignedURLs=true`
2. Check signature generation (HMAC-SHA256)
3. Ensure expiry in future
4. Verify signing key matches dashboard
5. Cannot use flexible variants (use named variants)
---
## Checking for Errors
**Response Headers**:
```javascript
const response = await fetch(transformedImageURL);
const cfResized = response.headers.get('Cf-Resized');
if (cfResized?.includes('err=')) {
console.error('Transformation error:', cfResized);
}
```
**Common patterns**:
- `Cf-Resized: err=9401` - Invalid arguments
- `Cf-Resized: err=9403` - Request loop
- `Cf-Resized: err=9412` - Non-image response
---
## Official Documentation
- **Troubleshooting**: https://developers.cloudflare.com/images/reference/troubleshooting/
- **Transform via Workers**: https://developers.cloudflare.com/images/transform-images/transform-via-workers/
references/transformation-options.md
# Image Transformation Options
Complete reference for all image transformation parameters.
Works with:
- **URL format**: `/cdn-cgi/image/<OPTIONS>/<SOURCE>`
- **Workers format**: `fetch(url, { cf: { image: {...} } })`
---
## Sizing
### width
Max width in pixels.
**URL**: `width=800` or `w=800`
**Workers**: `{ width: 800 }`
**Range**: 1-10000
### height
Max height in pixels.
**URL**: `height=600` or `h=600`
**Workers**: `{ height: 600 }`
**Range**: 1-10000
### dpr
Device pixel ratio for high-DPI displays.
**URL**: `dpr=2`
**Workers**: `{ dpr: 2 }`
**Range**: 1-3
**Example**: `dpr=2` serves 2x size for Retina displays
---
## Fit Modes
### fit
How to resize image.
**Options**:
- `scale-down`: Shrink to fit (never enlarge)
- `contain`: Resize to fit within dimensions (preserve aspect ratio)
- `cover`: Resize to fill dimensions (may crop)
- `crop`: Crop to exact dimensions
- `pad`: Resize and add padding
**URL**: `fit=cover`
**Workers**: `{ fit: 'cover' }`
**Default**: `scale-down`
**Examples**:
```
fit=scale-down: 800x600 image → max 400x300 → 400x300 (scaled down)
fit=scale-down: 400x300 image → max 800x600 → 400x300 (not enlarged)
fit=contain: Any size → 800x600 → Fits inside box, preserves aspect
fit=cover: Any size → 800x600 → Fills box, may crop edges
fit=crop: Any size → 800x600 → Exact size, crops as needed
fit=pad: 800x600 image → 1000x1000 → 800x600 + padding
```
---
## Quality & Format
### quality
JPEG/WebP quality.
**URL**: `quality=85` or `q=85`
**Workers**: `{ quality: 85 }`
**Range**: 1-100
**Default**: 85
**Recommended**: 80-90 for photos, 90-100 for graphics
### format
Output format.
**Options**:
- `auto`: Serve AVIF → WebP → Original based on browser support
- `avif`: Always AVIF (with WebP fallback)
- `webp`: Always WebP
- `jpeg`: JPEG (progressive)
- `baseline-jpeg`: JPEG (baseline, for older devices)
- `json`: Image metadata instead of image
**URL**: `format=auto` or `f=auto`
**Workers**: `{ format: 'auto' }`
**Default**: Original format
**Recommended**: `format=auto` for optimal delivery
### compression
WebP compression mode.
**Options**:
- `fast`: Faster encoding, larger file
- `lossless`: No quality loss
**URL**: `compression=fast`
**Workers**: `{ compression: 'fast' }`
---
## Cropping
### gravity
Crop focal point.
**Options**:
- `auto`: Smart crop based on saliency
- `face`: Crop to detected face
- `left`, `right`, `top`, `bottom`: Crop to side
- `XxY`: Coordinates (e.g., `0.5x0.5` for center)
**URL**: `gravity=face` or `gravity=0.5x0.3`
**Workers**: `{ gravity: 'face' }` or `{ gravity: { x: 0.5, y: 0.3 } }`
**Default**: `auto`
**Examples**:
```
gravity=auto: Smart crop to interesting area
gravity=face: Crop to detected face (if found)
gravity=0.5x0.5: Center crop
gravity=0x0: Top-left corner
gravity=1x1: Bottom-right corner
```
### zoom
Face cropping zoom level (when `gravity=face`).
**URL**: `zoom=0.5`
**Workers**: `{ zoom: 0.5 }`
**Range**: 0-1
**Default**: 0
**Behavior**: `0` = include background, `1` = crop close to face
### trim
Remove border (pixels to trim from edges).
**URL**: `trim=10`
**Workers**: `{ trim: 10 }`
**Range**: 0-100
---
## Effects
### blur
Gaussian blur radius.
**URL**: `blur=20`
**Workers**: `{ blur: 20 }`
**Range**: 1-250
**Use cases**: Privacy, background blur, LQIP placeholders
### sharpen
Sharpen intensity.
**URL**: `sharpen=3`
**Workers**: `{ sharpen: 3 }`
**Range**: 0-10
**Recommended**: 1-3 for subtle sharpening
### brightness
Brightness adjustment.
**URL**: `brightness=1.2`
**Workers**: `{ brightness: 1.2 }`
**Range**: 0-2
**Default**: 1 (no change)
**Examples**: `0.5` = darker, `1.5` = brighter
### contrast
Contrast adjustment.
**URL**: `contrast=1.1`
**Workers**: `{ contrast: 1.1 }`
**Range**: 0-2
**Default**: 1 (no change)
### gamma
Gamma correction.
**URL**: `gamma=1.5`
**Workers**: `{ gamma: 1.5 }`
**Range**: 0-2
**Default**: 1 (no change)
**Note**: `0` is ignored
---
## Rotation & Flipping
### rotate
Rotate image.
**Options**: `0`, `90`, `180`, `270`
**URL**: `rotate=90`
**Workers**: `{ rotate: 90 }`
### flip
Flip image.
**Options**:
- `h`: Horizontal flip
- `v`: Vertical flip
- `hv`: Both horizontal and vertical
**URL**: `flip=h`
**Workers**: `{ flip: 'h' }`
**Note**: Flipping is performed BEFORE rotation.
---
## Other
### background
Background color for transparency or padding.
**URL**: `background=rgb(255 0 0)` (CSS4 syntax)
**Workers**: `{ background: 'rgb(255 0 0)' }`
**Examples**:
- `background=white`
- `background=rgb(255 255 255)`
- `background=rgba(255 255 255 50)`
**Use with**: `fit=pad` or transparent images (PNG, WebP)
### metadata
EXIF metadata handling.
**Options**:
- `none`: Strip all metadata
- `copyright`: Keep only copyright tag
- `keep`: Preserve most EXIF metadata
**URL**: `metadata=keep`
**Workers**: `{ metadata: 'keep' }`
**Default**: `copyright` for JPEG, `none` for others
**Note**: Color profiles and EXIF rotation always applied, even if metadata stripped.
### anim
Preserve animation frames (GIF, WebP).
**URL**: `anim=false`
**Workers**: `{ anim: false }`
**Default**: `true`
**Use case**: Converting animated GIF to still image
---
## Combining Options
**URL Format**:
```
/cdn-cgi/image/width=800,height=600,fit=cover,quality=85,format=auto/image.jpg
```
**Workers Format**:
```javascript
fetch(imageURL, {
cf: {
image: {
width: 800,
height: 600,
fit: 'cover',
quality: 85,
format: 'auto'
}
}
});
```
---
## Common Presets
### Thumbnail
```
width=300,height=300,fit=cover,quality=85,format=auto
```
### Avatar
```
width=200,height=200,fit=cover,gravity=face,quality=90,format=auto
```
### Hero
```
width=1920,height=1080,fit=cover,quality=85,format=auto
```
### Blur Placeholder (LQIP)
```
width=50,quality=10,blur=20,format=webp
```
### Product Image
```
width=800,height=800,fit=contain,sharpen=2,quality=90,format=auto
```
### Responsive (Mobile)
```
width=480,quality=85,format=auto
```
### Responsive (Tablet)
```
width=768,quality=85,format=auto
```
### Responsive (Desktop)
```
width=1920,quality=85,format=auto
```
---
## Limits
- **Max dimensions**: 10,000 x 10,000 pixels
- **Max area**: 100 megapixels
- **Max file size**: No published limit (but 10MB recommended)
- **Quality range**: 1-100
- **DPR range**: 1-3
---
## Official Documentation
- **Transform via URL**: https://developers.cloudflare.com/images/transform-images/transform-via-url/
- **Transform via Workers**: https://developers.cloudflare.com/images/transform-images/transform-via-workers/
references/variants-guide.md
# Variants Guide - Named vs Flexible
Complete guide to Cloudflare Images variants.
---
## What Are Variants?
Variants define how images should be resized and transformed for different use cases.
**Two Types**:
1. **Named Variants** - Predefined transformations (up to 100)
2. **Flexible Variants** - Dynamic transformations (unlimited)
---
## Named Variants
### Overview
Pre-configured transformations that apply consistently across all images.
**Limits**: 100 variants per account
**Use with**: Public and private images (signed URLs compatible)
### Creating Named Variants
**Via API**:
```bash
curl "https://api.cloudflare.com/client/v4/accounts/{account_id}/images/v1/variants" \
--header "Authorization: Bearer <API_TOKEN>" \
--header "Content-Type: application/json" \
--data '{
"id": "thumbnail",
"options": {
"fit": "cover",
"width": 300,
"height": 300,
"metadata": "none"
},
"neverRequireSignedURLs": false
}'
```
**Via Dashboard**:
1. Dashboard → Images → Variants
2. Create variant
3. Set dimensions, fit mode, metadata handling
### Using Named Variants
**URL Format**:
```
https://imagedelivery.net/<ACCOUNT_HASH>/<IMAGE_ID>/<VARIANT_NAME>
```
**Example**:
```html
<img src="https://imagedelivery.net/Vi7wi5KSItxGFsWRG2Us6Q/abc123/thumbnail" />
```
### Common Named Variants
```javascript
const presets = {
thumbnail: { width: 300, height: 300, fit: 'cover' },
avatar: { width: 200, height: 200, fit: 'cover' },
small: { width: 480, fit: 'scale-down' },
medium: { width: 768, fit: 'scale-down' },
large: { width: 1920, fit: 'scale-down' },
hero: { width: 1920, height: 1080, fit: 'cover' },
product: { width: 800, height: 800, fit: 'contain' }
};
```
### When to Use Named Variants
✅ **Use when**:
- Consistent sizes needed across app
- Private images (signed URLs required)
- Predictable, simple URLs
- Team collaboration (shared definitions)
❌ **Don't use when**:
- Need dynamic sizing per request
- Rapid prototyping with many sizes
- Approaching 100-variant limit
---
## Flexible Variants
### Overview
Dynamic transformations using params directly in URL.
**Limits**: Unlimited transformations
**Use with**: Public images only (signed URLs NOT compatible)
### Enabling Flexible Variants
**One-time setup per account**:
```bash
curl --request PATCH \
https://api.cloudflare.com/client/v4/accounts/{account_id}/images/v1/config \
--header "Authorization: Bearer <API_TOKEN>" \
--header "Content-Type: application/json" \
--data '{"flexible_variants": true}'
```
### Using Flexible Variants
**URL Format**:
```
https://imagedelivery.net/<ACCOUNT_HASH>/<IMAGE_ID>/<TRANSFORMATION_PARAMS>
```
**Examples**:
```html
<!-- Basic resize -->
<img src="https://imagedelivery.net/HASH/ID/w=400,h=300" />
<!-- With quality and format -->
<img src="https://imagedelivery.net/HASH/ID/w=800,q=85,f=auto" />
<!-- Sharpen and crop -->
<img src="https://imagedelivery.net/HASH/ID/w=600,h=600,fit=cover,sharpen=3" />
<!-- Blur effect -->
<img src="https://imagedelivery.net/HASH/ID/w=500,blur=20,q=50" />
```
### Available Parameters
Same as transformation options:
- `w`, `h` - Width, height
- `fit` - Fit mode (scale-down, contain, cover, crop, pad)
- `q` - Quality (1-100)
- `f` - Format (auto, avif, webp, jpeg)
- `gravity` - Crop focal point (auto, face, left, right, top, bottom)
- `blur`, `sharpen`, `brightness`, `contrast`, `gamma`
- `rotate`, `flip`
- `dpr` - Device pixel ratio
- `metadata` - EXIF handling (none, copyright, keep)
- `anim` - Preserve animation (true/false)
### When to Use Flexible Variants
✅ **Use when**:
- Dynamic sizing needs
- Public images only
- Rapid prototyping
- User-controlled transformations
❌ **Don't use when**:
- Need signed URLs (private images)
- Want consistent, predictable URLs
- Team needs shared definitions
---
## Comparison Table
| Feature | Named Variants | Flexible Variants |
|---------|---------------|-------------------|
| **Limit** | 100 per account | Unlimited |
| **Signed URLs** | ✅ Compatible | ❌ Not compatible |
| **URL Format** | `/thumbnail` | `/w=400,h=300,fit=cover` |
| **URL Length** | Short, clean | Longer, dynamic |
| **Setup** | Create variants first | Enable once, use anywhere |
| **Use Case** | Consistent sizes | Dynamic sizing |
| **Team Sharing** | Shared definitions | Ad-hoc transformations |
| **Private Images** | ✅ Supported | ❌ Public only |
---
## Combining Both
You can use both types in the same account:
```html
<!-- Named variant for avatar (private image, signed URL) -->
<img src="https://imagedelivery.net/HASH/PRIVATE_ID/avatar?exp=123&sig=abc" />
<!-- Flexible variant for public thumbnail -->
<img src="https://imagedelivery.net/HASH/PUBLIC_ID/w=300,h=300,fit=cover" />
```
---
## Best Practices
### For Named Variants
1. **Create core sizes first**: thumbnail, small, medium, large
2. **Use descriptive names**: `product-square`, `hero-wide`, `avatar-round`
3. **Document variant usage**: Share definitions with team
4. **Set consistent quality**: 85 for photos, 90+ for graphics
5. **Use `metadata: none`**: Unless specific need to preserve EXIF
### For Flexible Variants
1. **Always use `f=auto`**: Optimal format for each browser
2. **Limit dynamic range**: Don't allow arbitrary sizes (performance)
3. **Cache popular sizes**: Create named variants for common sizes
4. **URL-encode params**: Especially if using special characters
5. **Public images only**: Remember signed URL incompatibility
---
## Migration Strategies
### From Flexible to Named
If approaching flexibility limits or need signed URLs:
```javascript
// Analyze usage logs
const popularSizes = analyzeImageRequests();
// { w=300,h=300: 50000, w=800,h=600: 30000, ... }
// Create named variants for top sizes
for (const [params, count] of Object.entries(popularSizes)) {
if (count > 10000) {
await createVariant(getNameForParams(params), parseParams(params));
}
}
// Update URLs from flexible to named
// Before: /w=300,h=300,fit=cover
// After: /thumbnail
```
### From Named to Flexible
If need more than 100 variants:
1. Enable flexible variants
2. Gradually migrate to dynamic params
3. Keep popular sizes as named variants
4. Use flexible for long-tail sizes
---
## Cost Considerations
**Named Variants**:
- Cached at edge (fast delivery)
- Predictable bandwidth
- Good for high traffic
**Flexible Variants**:
- Also cached at edge
- More cache keys (potentially)
- Good for diverse sizing needs
**Both**: First transformation billable, subsequent cached requests free
---
## Official Documentation
- **Create Variants**: https://developers.cloudflare.com/images/manage-images/create-variants/
- **Enable Flexible Variants**: https://developers.cloudflare.com/images/manage-images/enable-flexible-variants/
references/webhooks-guide.md
# Cloudflare Images Webhooks Guide
Complete guide to configuring and handling webhooks for Cloudflare Images upload notifications.
---
## What Are Webhooks?
Webhooks allow you to receive real-time notifications when images are uploaded to Cloudflare Images. Instead of polling the API, Cloudflare sends HTTP POST requests to your specified endpoint when events occur.
**Common Use Cases**:
- Process images after upload (resize, analyze, moderate)
- Update database records with image metadata
- Trigger workflows (send notifications, update UI)
- Audit and logging
- Content moderation pipelines
---
## Webhook Events
Cloudflare Images sends webhooks for these events:
### 1. Image Upload Success
**Trigger**: Image successfully uploaded via API or direct creator upload
**Payload**:
```json
{
"event": "image.uploaded",
"timestamp": "2025-01-15T10:30:45.123Z",
"accountId": "abc123",
"image": {
"id": "2cdc28f0-017a-49c4-9ed7-87056c83901",
"filename": "profile.jpg",
"uploaded": "2025-01-15T10:30:45.000Z",
"requireSignedURLs": false,
"variants": [
"https://imagedelivery.net/Vi7wi5KSItxGFsWRG2Us6Q/2cdc28f0.../public",
"https://imagedelivery.net/Vi7wi5KSItxGFsWRG2Us6Q/2cdc28f0.../thumbnail"
],
"metadata": {
"userId": "user_12345",
"source": "profile_upload"
}
}
}
```
---
## Configuring Webhooks
### Dashboard Configuration
1. **Navigate to Images Settings**:
- Dashboard → Images → Settings → Webhooks
2. **Add Webhook URL**:
- Enter your endpoint URL (must be HTTPS)
- Select events to receive
- (Optional) Add secret for signature verification
3. **Test Webhook**:
- Click "Test" to send sample payload
- Verify your endpoint receives and processes it
### API Configuration
```bash
curl --request PUT \
https://api.cloudflare.com/client/v4/accounts/{account_id}/images/v1/webhooks \
--header "Authorization: Bearer <API_TOKEN>" \
--header "Content-Type: application/json" \
--data '{
"url": "https://your-server.com/webhooks/images",
"events": ["image.uploaded"],
"secret": "your_webhook_secret_key"
}'
```
**Response**:
```json
{
"success": true,
"result": {
"id": "webhook_abc123",
"url": "https://your-server.com/webhooks/images",
"events": ["image.uploaded"],
"active": true,
"created": "2025-01-15T10:00:00Z"
}
}
```
---
## Implementing Webhook Handler
### Cloudflare Workers Example
**Complete webhook handler with signature verification**:
```typescript
// src/index.ts
interface ImageWebhook {
event: 'image.uploaded';
timestamp: string;
accountId: string;
image: {
id: string;
filename: string;
uploaded: string;
requireSignedURLs: boolean;
variants: string[];
metadata?: Record<string, string>;
};
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
// 1. Verify webhook signature
const signature = request.headers.get('X-Cloudflare-Signature');
if (!signature) {
return new Response('Missing signature', { status: 401 });
}
const body = await request.text();
const isValid = await verifySignature(body, signature, env.WEBHOOK_SECRET);
if (!isValid) {
return new Response('Invalid signature', { status: 401 });
}
// 2. Parse webhook payload
const webhook: ImageWebhook = JSON.parse(body);
// 3. Handle webhook event
try {
await handleImageUpload(webhook, env);
return new Response('Webhook processed', { status: 200 });
} catch (error) {
console.error('Webhook processing error:', error);
return new Response('Processing failed', { status: 500 });
}
}
};
async function verifySignature(
body: string,
signature: string,
secret: string
): Promise<boolean> {
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['verify']
);
const signatureBuffer = hexToBuffer(signature);
const dataBuffer = encoder.encode(body);
return crypto.subtle.verify('HMAC', key, signatureBuffer, dataBuffer);
}
function hexToBuffer(hex: string): ArrayBuffer {
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.substr(i, 2), 16);
}
return bytes.buffer;
}
async function handleImageUpload(webhook: ImageWebhook, env: Env) {
// Example: Save metadata to D1 database
await env.DB.prepare(
`INSERT INTO uploaded_images (id, filename, uploaded_at, user_id, variants)
VALUES (?, ?, ?, ?, ?)`
).bind(
webhook.image.id,
webhook.image.filename,
webhook.image.uploaded,
webhook.image.metadata?.userId || 'unknown',
JSON.stringify(webhook.image.variants)
).run();
// Example: Trigger additional processing
if (webhook.image.metadata?.requiresModeration) {
await env.MODERATION_QUEUE.send({
imageId: webhook.image.id,
timestamp: webhook.timestamp
});
}
console.log(`Processed image upload: ${webhook.image.id}`);
}
```
**wrangler.jsonc**:
```jsonc
{
"name": "images-webhook-handler",
"main": "src/index.ts",
"compatibility_date": "2025-01-15",
"vars": {
"WEBHOOK_SECRET": "your_webhook_secret_key"
},
"d1_databases": [
{
"binding": "DB",
"database_name": "images_metadata",
"database_id": "your-database-id"
}
],
"queues": {
"producers": [
{
"binding": "MODERATION_QUEUE",
"queue": "image-moderation"
}
]
}
}
```
---
## Security Best Practices
### 1. Signature Verification
**Always verify webhook signatures** to ensure requests come from Cloudflare:
```typescript
const isValid = await verifySignature(
requestBody,
request.headers.get('X-Cloudflare-Signature'),
env.WEBHOOK_SECRET
);
if (!isValid) {
return new Response('Unauthorized', { status: 401 });
}
```
### 2. Secret Management
- **Never hardcode secrets** in source code
- Use environment variables or secrets management
- Rotate webhook secrets periodically
- Use different secrets for dev/staging/production
### 3. Endpoint Security
- **Require HTTPS** for webhook endpoints
- **Validate payload structure** before processing
- **Rate limit** webhook endpoint to prevent abuse
- **Log all webhook attempts** for auditing
### 4. Error Handling
- Return `200 OK` only after successful processing
- Return `4xx` for client errors (invalid payload)
- Return `5xx` for server errors (processing failure)
- Cloudflare will retry failed webhooks (exponential backoff)
---
## Common Webhook Patterns
### Pattern 1: Database Update
```typescript
async function handleImageUpload(webhook: ImageWebhook, env: Env) {
// Update database with image metadata
await env.DB.prepare(
`INSERT INTO images (id, filename, user_id, uploaded_at)
VALUES (?, ?, ?, ?)`
).bind(
webhook.image.id,
webhook.image.filename,
webhook.image.metadata?.userId,
webhook.image.uploaded
).run();
}
```
### Pattern 2: Queue for Processing
```typescript
async function handleImageUpload(webhook: ImageWebhook, env: Env) {
// Send to queue for async processing
await env.IMAGE_QUEUE.send({
imageId: webhook.image.id,
action: 'generate_thumbnails',
variants: webhook.image.variants
});
}
```
### Pattern 3: Notification
```typescript
async function handleImageUpload(webhook: ImageWebhook, env: Env) {
// Send notification to user
await fetch(`${env.API_URL}/notifications`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
userId: webhook.image.metadata?.userId,
message: `Your image ${webhook.image.filename} has been uploaded`,
imageUrl: webhook.image.variants[0]
})
});
}
```
### Pattern 4: Content Moderation
```typescript
async function handleImageUpload(webhook: ImageWebhook, env: Env) {
// Send image to moderation API
const moderationResult = await fetch(`${env.MODERATION_API}/check`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
imageUrl: webhook.image.variants[0],
imageId: webhook.image.id
})
});
const result = await moderationResult.json();
if (!result.approved) {
// Delete image if moderation fails
await fetch(
`https://api.cloudflare.com/client/v4/accounts/${webhook.accountId}/images/v1/${webhook.image.id}`,
{
method: 'DELETE',
headers: { 'Authorization': `Bearer ${env.CF_API_TOKEN}` }
}
);
}
}
```
---
## Troubleshooting Webhooks
### Webhook Not Received
**Check**:
1. Endpoint URL is correct and accessible
2. Endpoint uses HTTPS (not HTTP)
3. Firewall allows Cloudflare IPs
4. Webhook is active in dashboard
5. Event type is enabled
**Test**:
```bash
# Send test webhook from dashboard
# Check server logs for incoming requests
```
### Signature Verification Fails
**Common Causes**:
- Wrong secret key
- Request body modified before verification
- Incorrect signature parsing (hex encoding)
**Solution**:
```typescript
// Log signature and body for debugging
console.log('Received signature:', signature);
console.log('Request body:', body);
// Verify secret matches dashboard configuration
```
### Webhook Timeout
**Cloudflare timeout**: 30 seconds
**Best Practice**:
- Acknowledge webhook immediately (return 200)
- Process asynchronously (queue, background job)
```typescript
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const webhook = await request.json();
// Acknowledge immediately
const response = new Response('Accepted', { status: 202 });
// Process in background
env.ctx.waitUntil(processWebhook(webhook, env));
return response;
}
};
```
---
## Webhook Retry Policy
Cloudflare automatically retries failed webhooks:
**Retry Schedule**:
1. Immediate retry
2. 1 minute later
3. 5 minutes later
4. 15 minutes later
5. 1 hour later
6. Give up after 24 hours
**Success Criteria**:
- HTTP status: 2xx (200-299)
- Response within 30 seconds
**Best Practice**: Return 200 OK as soon as webhook is received and validated, then process asynchronously.
---
## Testing Webhooks Locally
### Using ngrok
```bash
# 1. Start ngrok tunnel
ngrok http 8787
# 2. Configure webhook URL in dashboard
# URL: https://abc123.ngrok.io/webhooks
# 3. Start local dev server
npm run dev
# 4. Upload test image
# Webhook will be sent to your local server
```
### Manual Testing
```bash
# Send test webhook to local endpoint
curl --request POST \
http://localhost:8787/webhooks \
--header "X-Cloudflare-Signature: test_signature" \
--header "Content-Type: application/json" \
--data '{
"event": "image.uploaded",
"timestamp": "2025-01-15T10:30:00Z",
"accountId": "test",
"image": {
"id": "test_image_id",
"filename": "test.jpg",
"uploaded": "2025-01-15T10:30:00Z",
"requireSignedURLs": false,
"variants": ["https://example.com/test.jpg"],
"metadata": {"userId": "test_user"}
}
}'
```
---
## Production Checklist
Before deploying webhooks to production:
- [ ] Signature verification implemented
- [ ] Secrets stored in environment variables (not hardcoded)
- [ ] HTTPS endpoint with valid SSL certificate
- [ ] Error handling for all webhook processing steps
- [ ] Logging for audit trail
- [ ] Monitoring/alerting for webhook failures
- [ ] Tested with sample webhooks
- [ ] Retry logic handles idempotency
- [ ] Async processing for long-running operations
- [ ] Database transactions for data consistency
---
## Related References
- **Upload API**: See `references/api-reference.md`
- **Direct Creator Upload**: See `references/direct-upload-complete-workflow.md`
- **Error Handling**: See `references/top-errors.md`
---
## Official Documentation
- **Webhooks**: https://developers.cloudflare.com/images/manage-images/webhooks/
- **API Reference**: https://developers.cloudflare.com/api/resources/images/
scripts/analyze-usage.sh
#!/usr/bin/env bash
# Analyze Usage Script - Cloudflare Images
# Query API for storage usage, delivered images, and estimated costs
#
# Secrets loading: .env is sourced into the current shell only (set -a / set +a),
# not splashed into the environment of every child process via `export $(xargs)`.
# This avoids leaking CF_API_TOKEN into `ps`/child envs and respects shell
# quoting in the .env file. Secrets are unset at end of script.
# Your .env file should be mode 0600 (chmod 600 .env) since it holds API tokens.
set -e
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Load environment variables from .env if it exists.
# Use POSIX sourcing (set -a) rather than the legacy xargs-based export
# pattern so values with spaces, quotes, or special chars are handled
# correctly and we never pipe secret material through xargs.
if [ -f .env ]; then
set -a
. ./.env
set +a
fi
# Check required environment variables
if [ -z "$CF_ACCOUNT_ID" ]; then
echo -e "${RED}Error: CF_ACCOUNT_ID not set${NC}"
echo "Set it in .env or export CF_ACCOUNT_ID=your_account_id"
exit 1
fi
if [ -z "$CF_API_TOKEN" ]; then
echo -e "${RED}Error: CF_API_TOKEN not set${NC}"
echo "Set it in .env or export CF_API_TOKEN=your_api_token"
exit 1
fi
echo -e "${GREEN}Fetching Cloudflare Images Usage Stats...${NC}\n"
# Fetch usage stats
RESPONSE=$(curl -s -w "\n%{http_code}" -X GET \
"https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/images/v1/stats" \
-H "Authorization: Bearer ${CF_API_TOKEN}")
HTTP_CODE=$(echo "$RESPONSE" | tail -n 1)
BODY=$(echo "$RESPONSE" | sed '$d')
if [ "$HTTP_CODE" -ne 200 ]; then
echo -e "${RED}✗ Failed to fetch usage stats (HTTP $HTTP_CODE)${NC}"
echo -e "${YELLOW}Response: $BODY${NC}"
exit 1
fi
# Parse stats using jq or fallback
if command -v jq &> /dev/null; then
# Parse with jq
TOTAL_STORED=$(echo "$BODY" | jq -r '.result.count.current // 0')
TOTAL_ALLOWED=$(echo "$BODY" | jq -r '.result.count.allowed // 100000')
echo -e "${BLUE}═══════════════════════════════════════${NC}"
echo -e "${BLUE}Storage Statistics${NC}"
echo -e "${BLUE}═══════════════════════════════════════${NC}"
echo -e "${YELLOW}Images Stored:${NC} $TOTAL_STORED"
echo -e "${YELLOW}Storage Limit:${NC} $TOTAL_ALLOWED"
echo -e "${YELLOW}Storage Used:${NC} $(echo "scale=2; $TOTAL_STORED * 100 / $TOTAL_ALLOWED" | bc)%"
# Calculate estimated monthly cost (Images API pricing)
# $5 per 100,000 images stored
STORAGE_COST=$(echo "scale=2; $TOTAL_STORED / 100000 * 5" | bc)
echo ""
echo -e "${BLUE}═══════════════════════════════════════${NC}"
echo -e "${BLUE}Estimated Monthly Costs${NC}"
echo -e "${BLUE}═══════════════════════════════════════${NC}"
echo -e "${YELLOW}Storage Cost:${NC} \$$STORAGE_COST (at \$5/100k stored)"
# Note: Delivery cost requires analytics data which may not be available via stats API
echo -e "${YELLOW}Delivery Cost:${NC} Check Analytics dashboard"
echo -e "${GREEN} (\$1/100k delivered)${NC}"
echo ""
echo -e "${YELLOW}Transformations:${NC} Check Analytics dashboard"
echo -e "${GREEN} (\$0.50/1k transforms)${NC}"
echo -e "${GREEN} (100k/month free per zone)${NC}"
echo ""
echo -e "${BLUE}═══════════════════════════════════════${NC}"
echo -e "${BLUE}Storage Quota Status${NC}"
echo -e "${BLUE}═══════════════════════════════════════${NC}"
# Calculate remaining storage
REMAINING=$(( TOTAL_ALLOWED - TOTAL_STORED ))
PERCENT_USED=$(echo "scale=2; $TOTAL_STORED * 100 / $TOTAL_ALLOWED" | bc)
if (( $(echo "$PERCENT_USED >= 90" | bc -l) )); then
echo -e "${RED}⚠ WARNING: Storage quota at ${PERCENT_USED}%!${NC}"
echo -e "${RED} Only $REMAINING images remaining.${NC}"
echo -e "${YELLOW} Consider upgrading or cleaning up unused images.${NC}"
elif (( $(echo "$PERCENT_USED >= 70" | bc -l) )); then
echo -e "${YELLOW}⚠ Notice: Storage quota at ${PERCENT_USED}%.${NC}"
echo -e "${YELLOW} $REMAINING images remaining.${NC}"
else
echo -e "${GREEN}✓ Storage quota healthy (${PERCENT_USED}% used).${NC}"
echo -e "${GREEN} $REMAINING images remaining.${NC}"
fi
# Recommendations
echo ""
echo -e "${BLUE}═══════════════════════════════════════${NC}"
echo -e "${BLUE}Recommendations${NC}"
echo -e "${BLUE}═══════════════════════════════════════${NC}"
if (( TOTAL_STORED > 10000 )); then
echo -e "${YELLOW}→${NC} Use variants to reduce storage of multiple sizes"
echo -e "${YELLOW}→${NC} Enable automatic format conversion (WebP/AVIF)"
echo -e "${YELLOW}→${NC} Delete unused images to reduce costs"
fi
if (( $(echo "$PERCENT_USED < 10" | bc -l) )); then
echo -e "${GREEN}→${NC} Low usage - you're on track for minimal costs"
fi
echo -e "${GREEN}→${NC} Monitor usage in Dashboard → Images → Analytics"
echo -e "${GREEN}→${NC} Set up billing alerts for cost control"
else
# Fallback without jq
echo -e "${YELLOW}Note: Install 'jq' for better output formatting${NC}\n"
echo -e "${BLUE}═══════════════════════════════════════${NC}"
echo -e "${BLUE}Usage Statistics${NC}"
echo -e "${BLUE}═══════════════════════════════════════${NC}"
echo ""
echo -e "${YELLOW}Raw Response:${NC}"
echo "$BODY"
fi
echo ""
echo -e "${BLUE}═══════════════════════════════════════${NC}"
echo -e "${GREEN}Usage analysis complete!${NC}"
echo -e "${BLUE}═══════════════════════════════════════${NC}"
echo ""
echo -e "${YELLOW}For detailed analytics:${NC}"
echo -e " Dashboard → Images → Analytics"
echo -e " https://dash.cloudflare.com/?to=/:account/images/analytics"
# Drop secrets from the shell environment now that the curl call is done.
unset CF_ACCOUNT_ID CF_API_TOKEN
scripts/check-versions.sh
#!/bin/bash
# Cloudflare Images - Version Checker
# Verifies API endpoints are current
echo "Cloudflare Images - API Version Checker"
echo "========================================"
echo ""
# Colors
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m' # No Color
# Check if API token is set
if [ -z "$IMAGES_API_TOKEN" ]; then
echo -e "${YELLOW}WARNING:${NC} IMAGES_API_TOKEN environment variable not set"
echo "Set it with: export IMAGES_API_TOKEN=your_token_here"
echo ""
fi
if [ -z "$IMAGES_ACCOUNT_ID" ]; then
echo -e "${YELLOW}WARNING:${NC} IMAGES_ACCOUNT_ID environment variable not set"
echo "Set it with: export IMAGES_ACCOUNT_ID=your_account_id"
echo ""
fi
echo "Checking Cloudflare Images API endpoints..."
echo ""
# Check main API endpoint
echo -n "Checking /images/v1 endpoint... "
RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" \
"https://api.cloudflare.com/client/v4/accounts/${IMAGES_ACCOUNT_ID}/images/v1" \
-H "Authorization: Bearer ${IMAGES_API_TOKEN}" 2>/dev/null)
if [ "$RESPONSE" = "200" ] || [ "$RESPONSE" = "401" ]; then
echo -e "${GREEN}✓ Available${NC}"
else
echo -e "${RED}✗ Error (HTTP $RESPONSE)${NC}"
fi
# Check v2 endpoint
echo -n "Checking /images/v2 endpoint... "
RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" \
"https://api.cloudflare.com/client/v4/accounts/${IMAGES_ACCOUNT_ID}/images/v2" \
-H "Authorization: Bearer ${IMAGES_API_TOKEN}" 2>/dev/null)
if [ "$RESPONSE" = "200" ] || [ "$RESPONSE" = "401" ]; then
echo -e "${GREEN}✓ Available${NC}"
else
echo -e "${RED}✗ Error (HTTP $RESPONSE)${NC}"
fi
# Check direct upload endpoint
echo -n "Checking /images/v2/direct_upload endpoint... "
RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" \
-X POST \
"https://api.cloudflare.com/client/v4/accounts/${IMAGES_ACCOUNT_ID}/images/v2/direct_upload" \
-H "Authorization: Bearer ${IMAGES_API_TOKEN}" \
-H "Content-Type: application/json" \
-d '{}' 2>/dev/null)
if [ "$RESPONSE" = "200" ] || [ "$RESPONSE" = "400" ] || [ "$RESPONSE" = "401" ]; then
echo -e "${GREEN}✓ Available${NC}"
else
echo -e "${RED}✗ Error (HTTP $RESPONSE)${NC}"
fi
# Check batch API endpoint
echo -n "Checking batch.imagedelivery.net endpoint... "
RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" \
"https://batch.imagedelivery.net/images/v1" \
-H "Authorization: Bearer ${IMAGES_BATCH_TOKEN}" 2>/dev/null)
if [ "$RESPONSE" = "200" ] || [ "$RESPONSE" = "401" ]; then
echo -e "${GREEN}✓ Available${NC}"
else
echo -e "${YELLOW}⚠ Cannot verify (set IMAGES_BATCH_TOKEN if using)${NC}"
fi
echo ""
echo "Package Recommendations:"
echo "========================"
echo "TypeScript types: @cloudflare/workers-types@latest"
echo "Wrangler CLI: wrangler@latest"
echo ""
echo "No npm packages required for Cloudflare Images API"
echo "(uses native fetch API)"
echo ""
# Check if wrangler is installed
if command -v wrangler &> /dev/null; then
WRANGLER_VERSION=$(wrangler --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+')
echo -e "${GREEN}✓${NC} Wrangler installed: v$WRANGLER_VERSION"
else
echo -e "${YELLOW}⚠${NC} Wrangler not installed (optional)"
echo " Install: npm install -g wrangler"
fi
echo ""
echo "API Version: v2 (direct uploads), v1 (standard uploads)"
echo "Last Verified: 2025-10-26"
echo ""
echo -e "${GREEN}✓ All core endpoints available${NC}"
scripts/generate-signed-url.sh
#!/usr/bin/env bash
# Generate Signed URL Script - Cloudflare Images
# CLI tool to generate signed URLs for private images
#
# Secrets loading: .env is sourced into the current shell only (set -a / set +a),
# not splashed into the environment of every child process via `export $(xargs)`.
# This avoids leaking CF_IMAGES_SIGNING_KEY into `ps`/child envs and respects
# shell quoting in the .env file. Secrets are unset at end of script.
# Your .env file should be mode 0600 (chmod 600 .env) since it holds a signing key.
set -e
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Load environment variables from .env if it exists.
# Use POSIX sourcing (set -a) rather than the legacy xargs-based export
# pattern so values with spaces, quotes, or special chars are handled
# correctly and we never pipe secret material through xargs.
if [ -f .env ]; then
set -a
. ./.env
set +a
fi
# Usage
usage() {
echo -e "${YELLOW}Usage:${NC} $0 <image-id> [variant] [expiry-seconds]"
echo ""
echo "Arguments:"
echo " image-id Image ID (required)"
echo " variant Variant name (default: public)"
echo " expiry-seconds Expiry time in seconds (default: 3600 = 1 hour)"
echo ""
echo "Examples:"
echo " $0 2cdc28f0-017a-49c4-9ed7-87056c83901"
echo " $0 2cdc28f0-017a-49c4-9ed7-87056c83901 thumbnail"
echo " $0 2cdc28f0-017a-49c4-9ed7-87056c83901 public 7200"
echo ""
exit 1
}
# Check required environment variables
if [ -z "$CF_ACCOUNT_HASH" ]; then
echo -e "${RED}Error: CF_ACCOUNT_HASH not set${NC}"
echo "Set it in .env or export CF_ACCOUNT_HASH=your_account_hash"
echo ""
echo "Get it from: Dashboard → Images → Serving Images → Account Hash"
exit 1
fi
if [ -z "$CF_IMAGES_SIGNING_KEY" ]; then
echo -e "${RED}Error: CF_IMAGES_SIGNING_KEY not set${NC}"
echo "Set it in .env or export CF_IMAGES_SIGNING_KEY=your_signing_key"
echo ""
echo "Get it from: Dashboard → Images → Signing Keys → Create Key"
echo "Or generate with: openssl rand -hex 32"
exit 1
fi
# Parse arguments
IMAGE_ID="$1"
VARIANT="${2:-public}"
EXPIRY_SECONDS="${3:-3600}"
if [ -z "$IMAGE_ID" ]; then
usage
fi
echo -e "${GREEN}Generating Signed URL...${NC}\n"
# Calculate expiry timestamp (Unix epoch)
EXPIRY=$(( $(date +%s) + EXPIRY_SECONDS ))
# Data to sign: imageId/variant + expiry
DATA_TO_SIGN="${IMAGE_ID}/${VARIANT}${EXPIRY}"
# Generate HMAC-SHA256 signature
SIGNATURE=$(echo -n "$DATA_TO_SIGN" | openssl dgst -sha256 -hmac "$CF_IMAGES_SIGNING_KEY" -binary | base64)
# URL-encode the signature (replace + with -, / with _, remove =)
SIGNATURE_ENCODED=$(echo "$SIGNATURE" | tr '+/' '-_' | tr -d '=')
# Construct signed URL
SIGNED_URL="https://imagedelivery.net/${CF_ACCOUNT_HASH}/${IMAGE_ID}/${VARIANT}?exp=${EXPIRY}&sig=${SIGNATURE_ENCODED}"
# Calculate expiry date/time
EXPIRY_DATE=$(date -r $EXPIRY +"%Y-%m-%d %H:%M:%S %Z" 2>/dev/null || date -d @$EXPIRY +"%Y-%m-%d %H:%M:%S %Z" 2>/dev/null || echo "Unknown")
# Output
echo -e "${GREEN}Signed URL generated successfully!${NC}\n"
echo -e "${YELLOW}Image ID:${NC} $IMAGE_ID"
echo -e "${YELLOW}Variant:${NC} $VARIANT"
echo -e "${YELLOW}Expires In:${NC} $EXPIRY_SECONDS seconds ($(($EXPIRY_SECONDS / 60)) minutes)"
echo -e "${YELLOW}Expires At:${NC} $EXPIRY_DATE"
echo -e "${YELLOW}Signature:${NC} $SIGNATURE_ENCODED"
echo ""
echo -e "${GREEN}Signed URL:${NC}"
echo "$SIGNED_URL"
echo ""
# Test if URL is accessible
echo -e "${YELLOW}Testing URL accessibility...${NC}"
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "$SIGNED_URL")
if [ "$HTTP_CODE" -eq 200 ]; then
echo -e "${GREEN}✓ URL is accessible (HTTP $HTTP_CODE)${NC}"
else
echo -e "${RED}✗ URL is not accessible (HTTP $HTTP_CODE)${NC}"
echo -e "${YELLOW}This may indicate:${NC}"
echo -e " - Image does not exist"
echo -e " - Image does not require signed URLs"
echo -e " - Incorrect signing key"
echo -e " - Invalid variant"
fi
echo ""
echo -e "${GREEN}═══════════════════════════════════════${NC}"
echo -e "${GREEN}Copy the URL above to use in your application${NC}"
echo -e "${GREEN}═══════════════════════════════════════${NC}"
# Drop secrets from the shell environment now that the curl calls are done.
unset CF_ACCOUNT_HASH CF_IMAGES_SIGNING_KEY
scripts/test-upload.sh
#!/usr/bin/env bash
# Test Upload Script - Cloudflare Images
# Tests API connectivity with a sample image upload
#
# Secrets loading: .env is sourced into the current shell only (set -a / set +a),
# not splashed into the environment of every child process via `export $(xargs)`.
# This avoids leaking CF_API_TOKEN into `ps`/child envs and respects shell
# quoting in the .env file. Secrets are unset at end of script.
# Your .env file should be mode 0600 (chmod 600 .env) since it holds API tokens.
set -e
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Load environment variables from .env if it exists.
# Use POSIX sourcing (set -a) rather than the legacy xargs-based export
# pattern so values with spaces, quotes, or special chars are handled
# correctly and we never pipe secret material through xargs.
if [ -f .env ]; then
set -a
. ./.env
set +a
fi
# Check required environment variables
if [ -z "$CF_ACCOUNT_ID" ]; then
echo -e "${RED}Error: CF_ACCOUNT_ID not set${NC}"
echo "Set it in .env or export CF_ACCOUNT_ID=your_account_id"
exit 1
fi
if [ -z "$CF_API_TOKEN" ]; then
echo -e "${RED}Error: CF_API_TOKEN not set${NC}"
echo "Set it in .env or export CF_API_TOKEN=your_api_token"
exit 1
fi
# Check if test image exists
TEST_IMAGE="${1:-test-image.jpg}"
if [ ! -f "$TEST_IMAGE" ]; then
echo -e "${YELLOW}No test image found. Creating a 1x1 pixel test image...${NC}"
# Create a minimal test image (1x1 pixel JPEG)
echo -e "${YELLOW}Creating test-image.jpg...${NC}"
echo '/9j/4AAQSkZJRgABAQEASABIAAD/2wBDAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEB' | base64 -d > test-image.jpg
TEST_IMAGE="test-image.jpg"
fi
echo -e "${GREEN}Testing Cloudflare Images API...${NC}\n"
# Test 1: Upload image
echo -e "${YELLOW}Test 1: Uploading test image ($TEST_IMAGE)...${NC}"
RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \
"https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/images/v1" \
-H "Authorization: Bearer ${CF_API_TOKEN}" \
-F "file=@${TEST_IMAGE}")
HTTP_CODE=$(echo "$RESPONSE" | tail -n 1)
BODY=$(echo "$RESPONSE" | sed '$d')
if [ "$HTTP_CODE" -eq 200 ]; then
echo -e "${GREEN}✓ Upload successful (HTTP $HTTP_CODE)${NC}"
# Extract image ID
IMAGE_ID=$(echo "$BODY" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
if [ -n "$IMAGE_ID" ]; then
echo -e "${GREEN} Image ID: $IMAGE_ID${NC}\n"
# Test 2: Verify image is accessible
echo -e "${YELLOW}Test 2: Verifying image accessibility...${NC}"
# Get account hash from response
ACCOUNT_HASH=$(echo "$BODY" | grep -o '"accountHash":"[^"]*"' | cut -d'"' -f4)
if [ -n "$ACCOUNT_HASH" ]; then
IMAGE_URL="https://imagedelivery.net/${ACCOUNT_HASH}/${IMAGE_ID}/public"
# Try to fetch the image
IMAGE_HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "$IMAGE_URL")
if [ "$IMAGE_HTTP_CODE" -eq 200 ]; then
echo -e "${GREEN}✓ Image accessible at: $IMAGE_URL${NC}\n"
else
echo -e "${RED}✗ Image not accessible (HTTP $IMAGE_HTTP_CODE)${NC}\n"
fi
fi
# Test 3: Delete test image (cleanup)
echo -e "${YELLOW}Test 3: Cleaning up (deleting test image)...${NC}"
DELETE_RESPONSE=$(curl -s -w "\n%{http_code}" -X DELETE \
"https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/images/v1/${IMAGE_ID}" \
-H "Authorization: Bearer ${CF_API_TOKEN}")
DELETE_HTTP_CODE=$(echo "$DELETE_RESPONSE" | tail -n 1)
if [ "$DELETE_HTTP_CODE" -eq 200 ]; then
echo -e "${GREEN}✓ Test image deleted${NC}\n"
else
echo -e "${YELLOW}⚠ Could not delete test image (HTTP $DELETE_HTTP_CODE)${NC}"
echo -e "${YELLOW} You may need to delete it manually: $IMAGE_ID${NC}\n"
fi
echo -e "${GREEN}═══════════════════════════════════════${NC}"
echo -e "${GREEN}All tests passed! ✓${NC}"
echo -e "${GREEN}Cloudflare Images API is working correctly.${NC}"
echo -e "${GREEN}═══════════════════════════════════════${NC}"
else
echo -e "${RED}✗ Could not extract image ID from response${NC}"
echo -e "${YELLOW}Response: $BODY${NC}"
exit 1
fi
else
echo -e "${RED}✗ Upload failed (HTTP $HTTP_CODE)${NC}"
echo -e "${YELLOW}Response: $BODY${NC}"
exit 1
fi
# Drop secrets from the shell environment now that the curl calls are done.
unset CF_ACCOUNT_ID CF_API_TOKEN
scripts/validate-variants.sh
#!/usr/bin/env bash
# Validate Variants Script - Cloudflare Images
# List all configured variants and check variant count
#
# Secrets loading: .env is sourced into the current shell only (set -a / set +a),
# not splashed into the environment of every child process via `export $(xargs)`.
# This avoids leaking CF_API_TOKEN into `ps`/child envs and respects shell
# quoting in the .env file. Secrets are unset at end of script.
# Your .env file should be mode 0600 (chmod 600 .env) since it holds API tokens.
set -e
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0;' # No Color
# Load environment variables from .env if it exists.
# Use POSIX sourcing (set -a) rather than the legacy xargs-based export
# pattern so values with spaces, quotes, or special chars are handled
# correctly and we never pipe secret material through xargs.
if [ -f .env ]; then
set -a
. ./.env
set +a
fi
# Check required environment variables
if [ -z "$CF_ACCOUNT_ID" ]; then
echo -e "${RED}Error: CF_ACCOUNT_ID not set${NC}"
echo "Set it in .env or export CF_ACCOUNT_ID=your_account_id"
exit 1
fi
if [ -z "$CF_API_TOKEN" ]; then
echo -e "${RED}Error: CF_API_TOKEN not set${NC}"
echo "Set it in .env or export CF_API_TOKEN=your_api_token"
exit 1
fi
echo -e "${GREEN}Fetching Cloudflare Images Variants...${NC}\n"
# Fetch variants from API
RESPONSE=$(curl -s -w "\n%{http_code}" -X GET \
"https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/images/v1/variants" \
-H "Authorization: Bearer ${CF_API_TOKEN}")
HTTP_CODE=$(echo "$RESPONSE" | tail -n 1)
BODY=$(echo "$RESPONSE" | sed '$d')
if [ "$HTTP_CODE" -ne 200 ]; then
echo -e "${RED}✗ Failed to fetch variants (HTTP $HTTP_CODE)${NC}"
echo -e "${YELLOW}Response: $BODY${NC}"
exit 1
fi
# Check if jq is available for pretty JSON parsing
if command -v jq &> /dev/null; then
# Parse with jq
VARIANT_COUNT=$(echo "$BODY" | jq '.result.variants | length')
VARIANTS=$(echo "$BODY" | jq -r '.result.variants | keys[]')
echo -e "${BLUE}═══════════════════════════════════════${NC}"
echo -e "${BLUE}Variant Summary${NC}"
echo -e "${BLUE}═══════════════════════════════════════${NC}"
echo -e "${YELLOW}Total Variants:${NC} $VARIANT_COUNT / 100 (max)"
# Show warning if approaching limit
if [ "$VARIANT_COUNT" -ge 80 ]; then
echo -e "${RED}⚠ WARNING: Approaching variant limit!${NC}"
echo -e "${RED} You can create only $((100 - VARIANT_COUNT)) more variants.${NC}"
elif [ "$VARIANT_COUNT" -ge 50 ]; then
echo -e "${YELLOW}⚠ Notice: You have used $VARIANT_COUNT of 100 variants.${NC}"
else
echo -e "${GREEN}✓ Variant usage is healthy.${NC}"
fi
echo ""
echo -e "${BLUE}═══════════════════════════════════════${NC}"
echo -e "${BLUE}Configured Variants${NC}"
echo -e "${BLUE}═══════════════════════════════════════${NC}"
# Display each variant with details
for VARIANT in $VARIANTS; do
VARIANT_DATA=$(echo "$BODY" | jq -r ".result.variants[\"$VARIANT\"]")
# Extract variant options
WIDTH=$(echo "$VARIANT_DATA" | jq -r '.options.width // "auto"')
HEIGHT=$(echo "$VARIANT_DATA" | jq -r '.options.height // "auto"')
FIT=$(echo "$VARIANT_DATA" | jq -r '.options.fit // "scale-down"')
QUALITY=$(echo "$VARIANT_DATA" | jq -r '.options.quality // "85"')
echo -e "\n${GREEN}Variant:${NC} $VARIANT"
echo -e " ${YELLOW}Width:${NC} $WIDTH"
echo -e " ${YELLOW}Height:${NC} $HEIGHT"
echo -e " ${YELLOW}Fit:${NC} $FIT"
echo -e " ${YELLOW}Quality:${NC} $QUALITY"
done
else
# Fallback without jq
echo -e "${YELLOW}Note: Install 'jq' for better output formatting${NC}\n"
# Simple count using grep
VARIANT_COUNT=$(echo "$BODY" | grep -o '"id":"[^"]*"' | wc -l | tr -d ' ')
echo -e "${BLUE}═══════════════════════════════════════${NC}"
echo -e "${BLUE}Variant Summary${NC}"
echo -e "${BLUE}═══════════════════════════════════════${NC}"
echo -e "${YELLOW}Total Variants:${NC} $VARIANT_COUNT / 100 (max)"
# Show warning if approaching limit
if [ "$VARIANT_COUNT" -ge 80 ]; then
echo -e "${RED}⚠ WARNING: Approaching variant limit!${NC}"
elif [ "$VARIANT_COUNT" -ge 50 ]; then
echo -e "${YELLOW}⚠ Notice: You have used $VARIANT_COUNT of 100 variants.${NC}"
else
echo -e "${GREEN}✓ Variant usage is healthy.${NC}"
fi
echo ""
echo -e "${YELLOW}Raw Response:${NC}"
echo "$BODY"
fi
echo ""
echo -e "${BLUE}═══════════════════════════════════════${NC}"
echo -e "${GREEN}Variant validation complete!${NC}"
echo -e "${BLUE}═══════════════════════════════════════${NC}"
# Drop secrets from the shell environment now that the curl call is done.
unset CF_ACCOUNT_ID CF_API_TOKEN
SKILL.md
---
name: cloudflare-images
description: "This skill should be used when the user asks to \"upload images to Cloudflare\", \"implement direct creator upload\", \"configure image transformations\", \"optimize WebP/AVIF\", \"create image variants\", \"generate signed URLs\", \"add image watermarks\", \"integrate with Next.js/Remix\", \"configure webhooks\", \"debug CORS errors\", \"troubleshoot error 5408/9401-9413\", or \"build responsive images with Cloudflare Images API\"."
license: MIT
metadata:
version: "3.0.0"
last_verified: "2025-12-27"
workers_types_version: "4.20260408.0"
typescript_version: "5.7.2"
wrangler_version: "4.81.0"
production_tested: true
token_savings: "~65%"
errors_prevented: 10
templates_included: 16
references_included: 16
agents_included: 3
commands_included: 3
examples_included: 3
diagrams_included: 3
scripts_included: 5
keywords:
- cloudflare images
- image upload cloudflare
- imagedelivery.net
- cloudflare image transformations
- /cdn-cgi/image/
- direct creator upload
- image variants
- cf.image workers
- signed urls images
- flexible variants
- webp avif conversion
- responsive images cloudflare
- error 5408
- error 9401
- error 9403
- CORS direct upload
- multipart/form-data
- image optimization cloudflare
- image watermarks
- webhooks images
- nextjs cloudflare images
- remix cloudflare images
- custom domains images
- content credentials
- c2pa
---
# Cloudflare Images
**Status**: Production Ready ✅ | **Version**: 3.0.0 | **Last Verified**: 2025-12-27
---
## What Is Cloudflare Images?
Two powerful features:
1. **Images API**: Upload, store, serve images globally
2. **Image Transformations**: Resize/optimize ANY image
**Key benefits:**
- Global CDN delivery
- Automatic WebP/AVIF conversion
- Up to 100 variants
- Direct creator upload (no API keys in frontend)
- Signed URLs for private images
- Transform any image via URL or Workers
---
## Quick Start (5 Minutes)
### 1. Enable Cloudflare Images
Dashboard → **Images** → **Enable**
Get your **Account ID** and create **API token** (Cloudflare Images: Edit permission)
### 2. Upload Image
```bash
curl --request POST \
--url https://api.cloudflare.com/client/v4/accounts/<ACCOUNT_ID>/images/v1 \
--header 'Authorization: Bearer <API_TOKEN>' \
--header 'Content-Type: multipart/form-data' \
--form 'file=@./image.jpg'
```
**CRITICAL:** Use `multipart/form-data`, not JSON
### 3. Serve Image
```html
<img src="https://imagedelivery.net/<ACCOUNT_HASH>/<IMAGE_ID>/public" />
```
### 4. Enable Transformations
Dashboard → **Images** → **Transformations** → **Enable for zone**
Transform ANY image:
```html
<img src="/cdn-cgi/image/width=800,quality=85/uploads/photo.jpg" />
```
### 5. Transform via Workers
```typescript
export default {
async fetch(request: Request): Promise<Response> {
return fetch("https://example.com/image.jpg", {
cf: {
image: {
width: 800,
quality: 85,
format: "auto" // WebP/AVIF
}
}
});
}
};
```
**Load `references/setup-guide.md` for complete walkthrough.**
---
## The 3 Core Features
### Feature 1: Images API (Upload & Storage)
**Upload methods:**
1. File upload (server-side)
2. Upload via URL (ingest from external)
3. Direct creator upload (user uploads, no API keys)
**Load `templates/upload-api-basic.ts` for file upload example.**
**Load `references/direct-upload-complete-workflow.md` for user uploads.**
### Feature 2: Image Transformations
Optimize ANY image (uploaded or external).
**Methods:**
1. URL: `/cdn-cgi/image/width=800,quality=85/path/to/image.jpg`
2. Workers: `cf.image` fetch option
**Load `references/transformation-options.md` for all options.**
**Load `templates/transform-via-workers.ts` for Workers example.**
### Feature 3: Variants
Predefined transformations (up to 100).
**Examples:**
- `thumbnail`: 200x200, fit=cover
- `hero`: 1920x1080, quality=90
- `mobile`: 640, quality=75
**Load `references/variants-guide.md` for complete guide.**
---
## Critical Rules
### Always Do ✅
1. **Use multipart/form-data** for uploads (not JSON)
2. **Enable transformations for zones** before using `/cdn-cgi/image/`
3. **Use direct creator upload** for user uploads (don't expose API tokens)
4. **Set CORS headers** for direct uploads from browser
5. **Use signed URLs** for private images
6. **Configure variants** for common sizes (avoid dynamic transformations)
7. **Use format=auto** for automatic WebP/AVIF
8. **Handle error codes** (9401, 9403, 9413, 5408)
9. **Set quality=85** for optimal size/quality balance
10. **Use fit=cover** for consistent aspect ratios
### Never Do ❌
1. **Never expose API tokens** in frontend code
2. **Never use JSON encoding** for file uploads
3. **Never skip CORS configuration** for direct uploads
4. **Never exceed 100 variants** (hard limit)
5. **Never use transformations without enabling for zone**
6. **Never hardcode account IDs** in public code
7. **Never skip error handling** (uploads can fail)
8. **Never use quality >90** (diminishing returns)
9. **Never skip image validation** (size, format, dimensions)
10. **Never use transformations on non-proxied requests**
---
## Top 2 Use Cases
### Use Case 1: User Profile Pictures
Direct creator upload pattern for user-uploaded images:
```typescript
// Backend: Generate upload URL
const response = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${ACCOUNT_ID}/images/v2/direct_upload`,
{ method: 'POST', headers: { 'Authorization': `Bearer ${API_TOKEN}` } }
);
const { result } = await response.json();
return Response.json({ uploadURL: result.uploadURL });
// Frontend: Upload file
const formData = new FormData();
formData.append('file', file);
await fetch(uploadURL, { method: 'POST', body: formData });
```
**Load `templates/direct-creator-upload-backend.ts` for complete example.**
**See `examples/basic-upload/` for complete working project.**
### Use Case 2: Responsive Images
Responsive images with srcset for optimal performance:
```html
<img
srcset="
https://imagedelivery.net/abc/xyz/width=400 400w,
https://imagedelivery.net/abc/xyz/width=800 800w,
https://imagedelivery.net/abc/xyz/width=1200 1200w
"
sizes="(max-width: 600px) 400px, (max-width: 1000px) 800px, 1200px"
src="https://imagedelivery.net/abc/xyz/width=800"
/>
```
**Load `templates/responsive-images-srcset.html` for complete example.**
**See `examples/responsive-gallery/` for complete working project.**
**Additional Use Cases:**
- **Transform Existing Images**: Load `references/transformation-options.md`
- **Private Images**: Load `references/signed-urls-guide.md` or see `examples/private-images/`
- **Batch Upload**: Load `templates/batch-upload.ts`
- **Framework Integration**: Load `references/framework-integration.md` for Next.js, Remix, Astro
- **Watermarking**: Load `references/overlays-watermarks.md` and `templates/overlay-watermark.ts`
- **Custom Domains**: Load `references/custom-domains.md`
- **Webhooks**: Load `references/webhooks-guide.md` and `templates/webhook-handler.ts`
---
## Top 2 Errors Prevented
### Error 1: CORS Issues with Direct Upload
**Problem:** Browser blocks direct upload from your domain.
**Solution:** Configure CORS headers when generating upload URL:
```typescript
const response = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${ACCOUNT_ID}/images/v2/direct_upload`,
{
method: 'POST',
headers: { 'Authorization': `Bearer ${API_TOKEN}` },
body: JSON.stringify({
requireSignedURLs: false,
metadata: { source: 'user-upload' }
})
}
);
```
### Error 2: Multipart Form Data Encoding
**Problem:** JSON encoding fails for file uploads (must use multipart/form-data).
**Solution:**
```typescript
// ✅ CORRECT
const formData = new FormData();
formData.append('file', file);
await fetch(uploadURL, { method: 'POST', body: formData });
// ❌ WRONG
const json = JSON.stringify({ file: base64File });
```
**Additional Common Errors:**
- **Error 9401** (Transformations not enabled): Load `references/top-errors.md`
- **Error 9403** (Invalid transformation): Load `references/top-errors.md`
- **Error 9413** (Variant limit exceeded): Load `references/top-errors.md`
- **Error 5408** (Upload timeout): Load `references/top-errors.md`
- **Missing requireSignedURLs**: Load `references/signed-urls-guide.md`
**Load `references/top-errors.md` for all 10 errors with complete solutions.**
---
## When to Load References
### Core References
**Load `references/setup-guide.md` when:**
- First-time Cloudflare Images setup
- Need step-by-step walkthrough
**Load `references/api-reference.md` when:**
- Need complete API documentation
- All endpoints and parameters
**Load `references/top-errors.md` when:**
- Encountering any error code (5408, 9401-9413)
- Troubleshooting upload/transformation issues
### Upload References
**Load `references/direct-upload-complete-workflow.md` when:**
- Implementing user uploads
- Need frontend + backend example
- Configuring CORS
**Load `references/signed-urls-guide.md` when:**
- Implementing private images with access control
- Need HMAC-SHA256 signature generation
**Load `references/webhooks-guide.md` when:**
- Processing upload completion events
- Implementing webhook handlers with signature verification
### Transformation References
**Load `references/transformation-options.md` when:**
- Need complete transformation reference
- Exploring all fit/format/effect options
**Load `references/format-optimization.md` when:**
- Optimizing format selection (WebP/AVIF)
- Quality vs size tradeoffs
**Load `references/polish-compression.md` when:**
- Need details on Lossless/Lossy/WebP compression modes
- Metadata handling (EXIF removal)
**Load `references/overlays-watermarks.md` when:**
- Adding text or logo watermarks
- Implementing branding/copyright protection
### Advanced Features
**Load `references/variants-guide.md` when:**
- Creating/managing variants (up to 100 max)
- Need flexible variants vs named variants
**Load `references/responsive-images-patterns.md` when:**
- Building responsive images with srcset
- Implementing picture element for art direction
**Load `references/framework-integration.md` when:**
- Integrating with Next.js, Remix, Astro, SvelteKit
- Need framework-specific patterns and loaders
**Load `references/custom-domains.md` when:**
- Serving images from branded domains
- CNAME configuration and SSL setup
**Load `references/content-credentials.md` when:**
- Preserving EXIF/IPTC metadata
- Implementing C2PA Content Credentials for authenticity
**Load `references/sourcing-kit.md` when:**
- Migrating from Cloudinary, Imgix, or S3
- Bulk import from external CDNs
---
## Using Bundled Resources
### References (16 reference files)
**Core**: setup-guide.md, api-reference.md, top-errors.md
**Upload**: direct-upload-complete-workflow.md, signed-urls-guide.md, webhooks-guide.md
**Transform**: transformation-options.md, format-optimization.md, polish-compression.md, overlays-watermarks.md
**Advanced**: variants-guide.md, responsive-images-patterns.md, framework-integration.md, custom-domains.md, content-credentials.md, sourcing-kit.md
### Templates (16 template files)
**Upload**: upload-api-basic.ts, upload-via-url.ts, direct-creator-upload-backend.ts, direct-creator-upload-frontend.html, batch-upload.ts
**Transform**: transform-via-url.ts, transform-via-workers.ts, overlay-watermark.ts
**Variants**: variants-management.ts, signed-urls-generation.ts, responsive-images-srcset.html
**Integration**: nextjs-integration.tsx, remix-integration.tsx, webhook-handler.ts
**Config**: wrangler-images-binding.jsonc, package.json
### Agents (3 autonomous agents)
- **troubleshooting-agent** - Diagnose upload/transformation errors (5408, 9401-9413)
- **upload-workflow-agent** - Guide complete upload implementation (frontend + backend)
- **optimization-agent** - Recommend image optimization strategies
Use: `/agent <agent-name>` or let Claude auto-detect when relevant
### Commands (3 slash commands)
- **/check-images** - Quick API health check and configuration validation
- **/validate-config** - Validate wrangler.jsonc bindings and configuration
- **/generate-variant** - Interactive variant generator
Use: `/<command-name>`
### Examples (3 complete working projects)
- **basic-upload/** - Minimal upload implementation with Hono + Workers
- **responsive-gallery/** - Responsive image gallery with srcset and lazy loading
- **private-images/** - Signed URLs with time-based expiry and access control
Clone and run: `cd examples/<example-name> && npm install && npm run dev`
### Architecture Diagrams (3 diagrams)
- **direct-upload-workflow.md** - Sequence diagram of direct creator upload flow
- **transformation-pipeline.md** - Flowchart showing transformation processing
- **variants-structure.md** - Named vs flexible variants comparison
View in: `assets/diagrams/`
### Utility Scripts (5 scripts)
- **test-upload.sh** - Test API connectivity with sample image upload
- **generate-signed-url.sh** - CLI tool to generate signed URLs with expiry
- **validate-variants.sh** - List all variants and check variant count (max 100)
- **analyze-usage.sh** - Query API for storage usage and estimated costs
- **check-versions.sh** - Verify package versions are current
Run: `./scripts/<script-name>.sh` (requires CF_ACCOUNT_ID and CF_API_TOKEN in .env)
---
## Pricing
**Images API**: $5/100k stored, $1/100k delivered
**Transformations**: $0.50/1k (100k/month free per zone)
**Direct Upload**: Included in API pricing
---
## Official Documentation
- **Images Overview**: https://developers.cloudflare.com/images/
- **Upload API**: https://developers.cloudflare.com/images/upload-images/
- **Transformations**: https://developers.cloudflare.com/images/transform-images/
- **Direct Creator Upload**: https://developers.cloudflare.com/images/upload-images/direct-creator-upload/
- **Variants**: https://developers.cloudflare.com/images/manage-images/create-variants/
templates/batch-upload.ts
/**
* Cloudflare Images - Batch API
*
* High-volume image uploads using batch tokens.
*
* When to use:
* - Migrating thousands of images
* - Bulk upload workflows
* - Automated image ingestion
*
* IMPORTANT: Batch API uses different host and authentication
* - Host: batch.imagedelivery.net (NOT api.cloudflare.com)
* - Auth: Batch token (NOT regular API token)
*/
interface Env {
IMAGES_BATCH_TOKEN: string; // From Dashboard → Images → Batch API
}
interface BatchUploadOptions {
id?: string;
requireSignedURLs?: boolean;
metadata?: Record<string, string>;
}
interface CloudflareImagesResponse {
success: boolean;
result?: {
id: string;
filename: string;
uploaded: string;
requireSignedURLs: boolean;
variants: string[];
};
errors?: Array<{ code: number; message: string }>;
}
/**
* Upload single image via Batch API
*/
export async function batchUploadImage(
file: File,
options: BatchUploadOptions = {},
env: Env
): Promise<CloudflareImagesResponse> {
const formData = new FormData();
formData.append('file', file);
if (options.id) {
formData.append('id', options.id);
}
if (options.requireSignedURLs !== undefined) {
formData.append('requireSignedURLs', String(options.requireSignedURLs));
}
if (options.metadata) {
formData.append('metadata', JSON.stringify(options.metadata));
}
const response = await fetch('https://batch.imagedelivery.net/images/v1', {
method: 'POST',
headers: {
'Authorization': `Bearer ${env.IMAGES_BATCH_TOKEN}`
},
body: formData
});
return response.json();
}
/**
* Upload image via URL using Batch API
*/
export async function batchUploadViaURL(
imageUrl: string,
options: BatchUploadOptions = {},
env: Env
): Promise<CloudflareImagesResponse> {
const formData = new FormData();
formData.append('url', imageUrl);
if (options.id) {
formData.append('id', options.id);
}
if (options.requireSignedURLs !== undefined) {
formData.append('requireSignedURLs', String(options.requireSignedURLs));
}
if (options.metadata) {
formData.append('metadata', JSON.stringify(options.metadata));
}
const response = await fetch('https://batch.imagedelivery.net/images/v1', {
method: 'POST',
headers: {
'Authorization': `Bearer ${env.IMAGES_BATCH_TOKEN}`
},
body: formData
});
return response.json();
}
/**
* List images via Batch API
*/
export async function batchListImages(
page: number = 1,
perPage: number = 100,
env: Env
): Promise<{ success: boolean; result?: { images: unknown[] } }> {
const response = await fetch(
`https://batch.imagedelivery.net/images/v2?page=${page}&per_page=${perPage}`,
{
headers: {
'Authorization': `Bearer ${env.IMAGES_BATCH_TOKEN}`
}
}
);
return response.json();
}
/**
* Parallel batch upload (multiple images at once)
*/
export async function uploadMultipleImages(
images: Array<{ file?: File; url?: string; id?: string; metadata?: Record<string, string> }>,
concurrency: number = 5,
env: Env
): Promise<Array<{ input: unknown; result?: CloudflareImagesResponse; error?: string }>> {
const results: Array<{ input: unknown; result?: CloudflareImagesResponse; error?: string }> = [];
const chunks: typeof images[] = [];
// Split into chunks for parallel processing
for (let i = 0; i < images.length; i += concurrency) {
chunks.push(images.slice(i, i + concurrency));
}
// Process each chunk
for (const chunk of chunks) {
const promises = chunk.map(async (img) => {
try {
let result: CloudflareImagesResponse;
if (img.file) {
result = await batchUploadImage(img.file, { id: img.id, metadata: img.metadata }, env);
} else if (img.url) {
result = await batchUploadViaURL(img.url, { id: img.id, metadata: img.metadata }, env);
} else {
throw new Error('Must provide either file or url');
}
return { input: img, result };
} catch (error) {
return {
input: img,
error: error instanceof Error ? error.message : 'Upload failed'
};
}
});
const chunkResults = await Promise.all(promises);
results.push(...chunkResults);
}
return results;
}
/**
* Migration helper: Bulk ingest from URLs
*/
export async function migrateImagesFromURLs(
imageUrls: string[],
options: {
concurrency?: number;
prefix?: string; // ID prefix for all images
metadata?: Record<string, string>;
} = {},
env: Env
): Promise<{
successful: number;
failed: number;
results: Array<{ url: string; id?: string; error?: string }>;
}> {
const concurrency = options.concurrency || 5;
const successful: string[] = [];
const failed: string[] = [];
const results: Array<{ url: string; id?: string; error?: string }> = [];
const images = imageUrls.map((url, index) => ({
url,
id: options.prefix ? `${options.prefix}-${index}` : undefined,
metadata: options.metadata
}));
const uploadResults = await uploadMultipleImages(images, concurrency, env);
for (const result of uploadResults) {
const input = result.input as { url: string; id?: string };
if (result.error) {
failed.push(input.url);
results.push({ url: input.url, error: result.error });
} else {
successful.push(input.url);
results.push({ url: input.url, id: result.result?.result?.id });
}
}
return {
successful: successful.length,
failed: failed.length,
results
};
}
/**
* Example Worker
*/
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
// Bulk upload: POST /api/batch-upload
if (request.method === 'POST' && url.pathname === '/api/batch-upload') {
try {
const body = await request.json<{ imageUrls: string[] }>();
if (!body.imageUrls || !Array.isArray(body.imageUrls)) {
return Response.json({ error: 'imageUrls array required' }, { status: 400 });
}
const result = await migrateImagesFromURLs(
body.imageUrls,
{
concurrency: 5,
prefix: 'migration',
metadata: { source: 'bulk-upload' }
},
env
);
return Response.json(result);
} catch (error) {
return Response.json(
{ error: error instanceof Error ? error.message : 'Batch upload failed' },
{ status: 500 }
);
}
}
return Response.json({ error: 'Not found' }, { status: 404 });
}
};
/**
* Usage examples:
*
* ```typescript
* // Single upload via Batch API
* const result = await batchUploadImage(file, {
* metadata: { source: 'migration' }
* }, env);
*
* // Upload from URL
* const result = await batchUploadViaURL('https://example.com/image.jpg', {}, env);
*
* // Parallel upload multiple images
* const images = [
* { file: file1, id: 'image-1' },
* { file: file2, id: 'image-2' },
* { url: 'https://example.com/image3.jpg', id: 'image-3' }
* ];
* const results = await uploadMultipleImages(images, 5, env);
*
* // Migrate from URLs
* const urls = [
* 'https://old-cdn.example.com/image1.jpg',
* 'https://old-cdn.example.com/image2.jpg',
* // ... thousands more
* ];
* const migration = await migrateImagesFromURLs(urls, {
* concurrency: 10,
* prefix: 'migrated',
* metadata: { migratedAt: new Date().toISOString() }
* }, env);
*
* console.log(`Successful: ${migration.successful}, Failed: ${migration.failed}`);
* ```
*
* SETUP:
* 1. Dashboard → Images → Batch API
* 2. Create batch token
* 3. Add to wrangler.toml: wrangler secret put IMAGES_BATCH_TOKEN
*
* DIFFERENCES FROM REGULAR API:
* - Host: batch.imagedelivery.net (NOT api.cloudflare.com)
* - Auth: Batch token (NOT regular API token)
* - Same endpoints: /images/v1, /images/v2
* - Rate limits may differ (contact Cloudflare for high-volume needs)
*/
templates/direct-creator-upload-backend.ts
/**
* Cloudflare Images - Direct Creator Upload (Backend)
*
* Generate one-time upload URLs for users to upload directly to Cloudflare.
*
* Architecture:
* 1. Frontend requests upload URL from this backend
* 2. Backend calls Cloudflare /direct_upload API
* 3. Backend returns uploadURL to frontend
* 4. Frontend uploads directly to Cloudflare using uploadURL
*
* Benefits:
* - No API key exposure to browser
* - Users upload directly to Cloudflare (faster)
* - No intermediary storage needed
*/
interface Env {
IMAGES_ACCOUNT_ID: string;
IMAGES_API_TOKEN: string;
}
interface DirectUploadOptions {
requireSignedURLs?: boolean;
metadata?: Record<string, string>;
expiry?: string; // ISO 8601 format (default: 30min, max: 6hr)
id?: string; // Custom ID (optional)
}
interface DirectUploadResponse {
success: boolean;
result?: {
id: string; // Image ID that will be uploaded
uploadURL: string; // One-time upload URL for frontend
};
errors?: Array<{ code: number; message: string }>;
}
/**
* Generate one-time upload URL
*/
export async function generateUploadURL(
options: DirectUploadOptions = {},
env: Env
): Promise<DirectUploadResponse> {
const requestBody: Record<string, unknown> = {};
// Optional: Require signed URLs for private images
if (options.requireSignedURLs !== undefined) {
requestBody.requireSignedURLs = options.requireSignedURLs;
}
// Optional: Metadata (attached to image, not visible to end users)
if (options.metadata) {
requestBody.metadata = options.metadata;
}
// Optional: Expiry (default 30min, max 6hr from now)
if (options.expiry) {
requestBody.expiry = options.expiry;
}
// Optional: Custom ID (cannot use with requireSignedURLs=true)
if (options.id) {
requestBody.id = options.id;
}
const response = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${env.IMAGES_ACCOUNT_ID}/images/v2/direct_upload`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${env.IMAGES_API_TOKEN}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(requestBody)
}
);
const result: DirectUploadResponse = await response.json();
if (!result.success) {
console.error('Failed to generate upload URL:', result.errors);
throw new Error(`Failed to generate upload URL: ${result.errors?.[0]?.message || 'Unknown error'}`);
}
return result;
}
/**
* Example Cloudflare Worker endpoint
*/
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
// CORS headers for frontend
const corsHeaders = {
'Access-Control-Allow-Origin': '*', // Replace with your domain
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type'
};
// Handle preflight
if (request.method === 'OPTIONS') {
return new Response(null, { headers: corsHeaders });
}
// Endpoint: POST /api/upload-url
if (request.method === 'POST' && url.pathname === '/api/upload-url') {
try {
const body = await request.json<{
userId?: string;
requireSignedURLs?: boolean;
}>();
// Generate upload URL
const result = await generateUploadURL(
{
requireSignedURLs: body.requireSignedURLs ?? false,
metadata: {
userId: body.userId || 'anonymous',
uploadedAt: new Date().toISOString()
},
// Set expiry: 1 hour from now
expiry: new Date(Date.now() + 60 * 60 * 1000).toISOString()
},
env
);
return Response.json(
{
success: true,
uploadURL: result.result?.uploadURL,
imageId: result.result?.id
},
{ headers: corsHeaders }
);
} catch (error) {
return Response.json(
{ error: error instanceof Error ? error.message : 'Failed to generate upload URL' },
{ status: 500, headers: corsHeaders }
);
}
}
return Response.json({ error: 'Not found' }, { status: 404 });
}
};
/**
* Check upload status (useful with webhooks)
*/
export async function checkImageStatus(
imageId: string,
env: Env
): Promise<{
success: boolean;
result?: {
id: string;
uploaded: string;
draft?: boolean; // true if upload not completed yet
variants?: string[];
};
}> {
const response = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${env.IMAGES_ACCOUNT_ID}/images/v1/${imageId}`,
{
headers: {
'Authorization': `Bearer ${env.IMAGES_API_TOKEN}`
}
}
);
return response.json();
}
/**
* Usage example:
*
* Frontend calls this endpoint:
* ```javascript
* const response = await fetch('/api/upload-url', {
* method: 'POST',
* headers: { 'Content-Type': 'application/json' },
* body: JSON.stringify({ userId: '12345' })
* });
* const { uploadURL, imageId } = await response.json();
*
* // Now frontend can upload directly to uploadURL
* const formData = new FormData();
* formData.append('file', fileInput.files[0]); // MUST be named 'file'
*
* await fetch(uploadURL, {
* method: 'POST',
* body: formData // NO Content-Type header
* });
* ```
*
* Custom expiry:
* ```typescript
* const result = await generateUploadURL({
* expiry: new Date('2025-10-26T18:00:00Z').toISOString(), // Specific time
* metadata: { purpose: 'profile-photo' }
* }, env);
* ```
*/
templates/direct-creator-upload-frontend.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Direct Creator Upload - Cloudflare Images</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, -apple-system, sans-serif;
padding: 2rem;
max-width: 600px;
margin: 0 auto;
}
h1 { margin-bottom: 2rem; }
.upload-form { display: flex; flex-direction: column; gap: 1rem; }
.file-input-wrapper {
border: 2px dashed #ccc;
border-radius: 8px;
padding: 2rem;
text-align: center;
cursor: pointer;
transition: all 0.2s;
}
.file-input-wrapper:hover { border-color: #007bff; background: #f8f9fa; }
.file-input-wrapper.dragover { border-color: #28a745; background: #e7f5e9; }
input[type="file"] { display: none; }
button {
padding: 0.75rem 1.5rem;
background: #007bff;
color: white;
border: none;
border-radius: 6px;
font-size: 1rem;
cursor: pointer;
transition: background 0.2s;
}
button:hover:not(:disabled) { background: #0056b3; }
button:disabled { background: #6c757d; cursor: not-allowed; }
.progress {
height: 30px;
background: #e9ecef;
border-radius: 6px;
overflow: hidden;
display: none;
}
.progress-bar {
height: 100%;
background: #28a745;
transition: width 0.3s;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: bold;
}
.message {
padding: 1rem;
border-radius: 6px;
display: none;
}
.message.success { background: #d4edda; color: #155724; border: 1px solid #c3e6cb; }
.message.error { background: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; }
.preview { margin-top: 1rem; max-width: 100%; border-radius: 6px; display: none; }
</style>
</head>
<body>
<h1>Upload Image to Cloudflare</h1>
<form id="upload-form" class="upload-form">
<label for="file-input" class="file-input-wrapper" id="drop-zone">
<div>
<p><strong>Choose a file</strong> or drag it here</p>
<p style="margin-top: 0.5rem; color: #666;">Max 10MB, JPEG/PNG/WebP/GIF</p>
</div>
<input type="file" id="file-input" accept="image/*" />
</label>
<div id="file-name" style="color: #666;"></div>
<img id="preview" class="preview" alt="Preview" />
<button type="submit" id="upload-btn" disabled>Upload Image</button>
<div class="progress" id="progress">
<div class="progress-bar" id="progress-bar">0%</div>
</div>
<div id="message" class="message"></div>
</form>
<script>
// Configuration
const API_ENDPOINT = '/api/upload-url'; // Your backend endpoint
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
// Elements
const form = document.getElementById('upload-form');
const fileInput = document.getElementById('file-input');
const dropZone = document.getElementById('drop-zone');
const fileName = document.getElementById('file-name');
const preview = document.getElementById('preview');
const uploadBtn = document.getElementById('upload-btn');
const progress = document.getElementById('progress');
const progressBar = document.getElementById('progress-bar');
const message = document.getElementById('message');
let selectedFile = null;
// File input change
fileInput.addEventListener('change', handleFileSelect);
// Drag and drop
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('dragover');
});
dropZone.addEventListener('dragleave', () => {
dropZone.classList.remove('dragover');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('dragover');
const files = e.dataTransfer.files;
if (files.length > 0) {
fileInput.files = files;
handleFileSelect();
}
});
// Handle file selection
function handleFileSelect() {
selectedFile = fileInput.files[0];
if (!selectedFile) {
return;
}
// Validate file size
if (selectedFile.size > MAX_FILE_SIZE) {
showMessage(`File too large (${(selectedFile.size / 1024 / 1024).toFixed(2)}MB). Max 10MB.`, 'error');
resetForm();
return;
}
// Validate file type
if (!selectedFile.type.startsWith('image/')) {
showMessage('Please select an image file.', 'error');
resetForm();
return;
}
// Show file name
fileName.textContent = `Selected: ${selectedFile.name} (${(selectedFile.size / 1024 / 1024).toFixed(2)}MB)`;
// Show preview
const reader = new FileReader();
reader.onload = (e) => {
preview.src = e.target.result;
preview.style.display = 'block';
};
reader.readAsDataURL(selectedFile);
// Enable upload button
uploadBtn.disabled = false;
}
// Form submission
form.addEventListener('submit', async (e) => {
e.preventDefault();
if (!selectedFile) {
showMessage('Please select a file', 'error');
return;
}
try {
// Disable form
uploadBtn.disabled = true;
fileInput.disabled = true;
progress.style.display = 'block';
message.style.display = 'none';
// Step 1: Get upload URL from backend
showProgress(10, 'Requesting upload URL...');
const urlResponse = await fetch(API_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
userId: 'user-123', // Replace with actual user ID
requireSignedURLs: false
})
});
if (!urlResponse.ok) {
throw new Error('Failed to get upload URL');
}
const { uploadURL, imageId } = await urlResponse.json();
// Step 2: Upload directly to Cloudflare
showProgress(30, 'Uploading...');
const formData = new FormData();
formData.append('file', selectedFile); // MUST be named 'file'
const uploadResponse = await fetch(uploadURL, {
method: 'POST',
body: formData
// NO Content-Type header - browser sets multipart/form-data automatically
});
if (!uploadResponse.ok) {
throw new Error(`Upload failed: ${uploadResponse.statusText}`);
}
const uploadResult = await uploadResponse.json();
showProgress(100, 'Complete!');
// Success
setTimeout(() => {
showMessage(`✓ Upload successful! Image ID: ${imageId}`, 'success');
progress.style.display = 'none';
}, 500);
} catch (error) {
console.error('Upload error:', error);
showMessage(`✗ Upload failed: ${error.message}`, 'error');
progress.style.display = 'none';
uploadBtn.disabled = false;
fileInput.disabled = false;
}
});
// Helper: Show progress
function showProgress(percent, text) {
progressBar.style.width = `${percent}%`;
progressBar.textContent = text || `${percent}%`;
}
// Helper: Show message
function showMessage(text, type) {
message.textContent = text;
message.className = `message ${type}`;
message.style.display = 'block';
}
// Helper: Reset form
function resetForm() {
selectedFile = null;
fileInput.value = '';
fileName.textContent = '';
preview.style.display = 'none';
uploadBtn.disabled = true;
}
</script>
</body>
</html>
<!--
CRITICAL CORS FIX:
✅ CORRECT:
const formData = new FormData();
formData.append('file', selectedFile); // Name MUST be 'file'
await fetch(uploadURL, {
method: 'POST',
body: formData // Browser sets multipart/form-data automatically
});
❌ WRONG:
await fetch(uploadURL, {
headers: { 'Content-Type': 'application/json' }, // CORS error
body: JSON.stringify({ file: base64Image })
});
ARCHITECTURE:
1. Frontend → POST /api/upload-url → Backend
2. Backend → POST /direct_upload → Cloudflare API
3. Backend → Returns uploadURL → Frontend
4. Frontend → Uploads to uploadURL → Cloudflare
5. Cloudflare → Returns success → Frontend
WHY:
- No API key exposure to browser
- Users upload directly to Cloudflare (faster)
- multipart/form-data required (CORS)
- Field name MUST be 'file'
-->
templates/nextjs-integration.tsx
/**
* Next.js 15 + Cloudflare Images Integration
*
* Complete integration for Next.js Image component with Cloudflare Images.
* Works with both Pages Router and App Router.
*
* Features:
* - Custom image loader for Next.js
* - Reusable CloudflareImage component
* - Server-side upload API route
* - TypeScript support
* - Format auto-detection (WebP/AVIF)
* - Responsive images with srcset
*
* Setup:
* 1. Add loader configuration to next.config.js
* 2. Set environment variables
* 3. Copy components to your project
*/
// ===== 1. next.config.js =====
/*
module.exports = {
images: {
loader: 'custom',
loaderFile: './lib/cloudflare-image-loader.ts',
formats: ['image/avif', 'image/webp'],
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
},
};
*/
// ===== 2. lib/cloudflare-image-loader.ts =====
/**
* Custom image loader for Next.js Image component
* Generates Cloudflare Images URLs with proper transformations
*/
export default function cloudflareLoader({
src,
width,
quality
}: {
src: string;
width: number;
quality?: number;
}) {
const params = new URLSearchParams();
params.set('width', width.toString());
params.set('quality', (quality || 85).toString());
params.set('format', 'auto'); // Automatic WebP/AVIF
// Extract image ID from src (assuming src is the Cloudflare image ID)
const imageId = src.startsWith('/') ? src.slice(1) : src;
// Use custom domain or default imagedelivery.net
const DOMAIN = process.env.NEXT_PUBLIC_CF_IMAGES_DOMAIN;
const ACCOUNT_HASH = process.env.NEXT_PUBLIC_CF_ACCOUNT_HASH;
const VARIANT = process.env.NEXT_PUBLIC_CF_DEFAULT_VARIANT || 'public';
if (DOMAIN) {
// Custom domain (e.g., images.yourdomain.com)
return `https://${DOMAIN}/${imageId}/${VARIANT}?${params}`;
}
// Default imagedelivery.net
return `https://imagedelivery.net/${ACCOUNT_HASH}/${imageId}/${VARIANT}?${params}`;
}
// ===== 3. .env.local =====
/*
# Cloudflare Images Configuration
NEXT_PUBLIC_CF_IMAGES_DOMAIN=images.yourdomain.com
# OR use default imagedelivery.net
NEXT_PUBLIC_CF_ACCOUNT_HASH=Vi7wi5KSItxGFsWRG2Us6Q
NEXT_PUBLIC_CF_DEFAULT_VARIANT=public
# Server-side only (for uploads)
CF_ACCOUNT_ID=your_account_id
CF_API_TOKEN=your_api_token
*/
// ===== 4. components/CloudflareImage.tsx (App Router) =====
import Image, { ImageProps } from 'next/image';
interface CloudflareImageProps extends Omit<ImageProps, 'src' | 'loader'> {
/**
* Cloudflare Images ID
* Example: "2cdc28f0-017a-49c4-9ed7-87056c83901"
*/
imageId: string;
/**
* Variant name (optional)
* Default: 'public'
*/
variant?: string;
}
/**
* CloudflareImage Component
*
* Wrapper around Next.js Image component for Cloudflare Images.
* Automatically uses the custom loader configured in next.config.js.
*
* @example
* <CloudflareImage
* imageId="2cdc28f0-017a-49c4-9ed7-87056c83901"
* alt="Product photo"
* width={800}
* height={600}
* priority
* />
*/
export function CloudflareImage({
imageId,
variant = 'public',
...imageProps
}: CloudflareImageProps) {
// src is just the image ID, loader handles the rest
return <Image src={imageId} {...imageProps} />;
}
// ===== 5. app/api/upload/route.ts (Upload API Route) =====
import { NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
try {
// 1. Get file from form data
const formData = await request.formData();
const file = formData.get('file') as File;
if (!file) {
return NextResponse.json({ error: 'No file provided' }, { status: 400 });
}
// 2. Validate file
const maxSize = 10 * 1024 * 1024; // 10 MB
if (file.size > maxSize) {
return NextResponse.json({ error: 'File too large (max 10MB)' }, { status: 400 });
}
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
if (!allowedTypes.includes(file.type)) {
return NextResponse.json({ error: 'Invalid file type' }, { status: 400 });
}
// 3. Upload to Cloudflare Images
const cloudflareFormData = new FormData();
cloudflareFormData.append('file', file);
// Optional: Add metadata
const userId = request.headers.get('X-User-ID');
if (userId) {
cloudflareFormData.append(
'metadata',
JSON.stringify({
userId,
uploadedAt: new Date().toISOString(),
source: 'nextjs-app'
})
);
}
const uploadResponse = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${process.env.CF_ACCOUNT_ID}/images/v1`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.CF_API_TOKEN}`
},
body: cloudflareFormData
}
);
const result = await uploadResponse.json();
if (!result.success) {
console.error('Cloudflare upload failed:', result.errors);
return NextResponse.json({ error: 'Upload failed' }, { status: 500 });
}
// 4. Return image info
return NextResponse.json({
success: true,
imageId: result.result.id,
variants: result.result.variants,
filename: file.name,
size: file.size
});
} catch (error) {
console.error('Upload error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
// ===== 6. app/upload/page.tsx (Upload Page Example) =====
'use client';
import { useState, FormEvent } from 'react';
import { CloudflareImage } from '@/components/CloudflareImage';
export default function UploadPage() {
const [uploading, setUploading] = useState(false);
const [uploadedImageId, setUploadedImageId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
setError(null);
const formData = new FormData(e.currentTarget);
const file = formData.get('file') as File;
if (!file || file.size === 0) {
setError('Please select a file');
return;
}
setUploading(true);
try {
const response = await fetch('/api/upload', {
method: 'POST',
body: formData
});
const result = await response.json();
if (result.success) {
setUploadedImageId(result.imageId);
} else {
setError(result.error || 'Upload failed');
}
} catch (err) {
setError('Upload failed');
console.error(err);
} finally {
setUploading(false);
}
}
return (
<div className="max-w-2xl mx-auto p-8">
<h1 className="text-3xl font-bold mb-6">Upload Image</h1>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="file" className="block text-sm font-medium mb-2">
Select Image
</label>
<input
type="file"
id="file"
name="file"
accept="image/*"
required
className="block w-full border border-gray-300 rounded-lg p-2"
/>
</div>
<button
type="submit"
disabled={uploading}
className="px-4 py-2 bg-blue-600 text-white rounded-lg disabled:opacity-50"
>
{uploading ? 'Uploading...' : 'Upload'}
</button>
{error && <p className="text-red-600">{error}</p>}
</form>
{uploadedImageId && (
<div className="mt-8">
<h2 className="text-xl font-semibold mb-4">Uploaded Image</h2>
<CloudflareImage
imageId={uploadedImageId}
alt="Uploaded image"
width={800}
height={600}
className="rounded-lg"
/>
<p className="mt-2 text-sm text-gray-600">Image ID: {uploadedImageId}</p>
</div>
)}
</div>
);
}
// ===== 7. app/gallery/page.tsx (Gallery Example) =====
import { CloudflareImage } from '@/components/CloudflareImage';
// This would typically come from your database
const galleryImages = [
{
id: '1',
cloudflareId: '2cdc28f0-017a-49c4-9ed7-87056c83901',
alt: 'Product 1',
title: 'Modern Chair'
},
{
id: '2',
cloudflareId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
alt: 'Product 2',
title: 'Wooden Table'
}
// Add more images...
];
export default function GalleryPage() {
return (
<div className="max-w-7xl mx-auto p-8">
<h1 className="text-3xl font-bold mb-8">Image Gallery</h1>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{galleryImages.map((image) => (
<div key={image.id} className="group">
<CloudflareImage
imageId={image.cloudflareId}
alt={image.alt}
width={400}
height={300}
className="rounded-lg shadow-lg group-hover:scale-105 transition-transform"
/>
<h3 className="mt-2 font-semibold">{image.title}</h3>
</div>
))}
</div>
</div>
);
}
// ===== 8. TypeScript Types =====
/*
// types/cloudflare-images.ts
export interface CloudflareImageMetadata {
id: string;
filename: string;
uploaded: string;
requireSignedURLs: boolean;
variants: string[];
metadata?: Record<string, string>;
}
export interface CloudflareUploadResult {
success: boolean;
result: CloudflareImageMetadata;
errors?: Array<{ code: number; message: string }>;
}
*/
// ===== 9. Responsive Images with srcset =====
/*
// components/ResponsiveImage.tsx
import Image from 'next/image';
interface ResponsiveImageProps {
imageId: string;
alt: string;
sizes: string;
priority?: boolean;
}
export function ResponsiveImage({
imageId,
alt,
sizes,
priority = false
}: ResponsiveImageProps) {
return (
<Image
src={imageId}
alt={alt}
width={1920}
height={1080}
sizes={sizes}
priority={priority}
className="w-full h-auto"
/>
);
}
// Usage
<ResponsiveImage
imageId="your-image-id"
alt="Hero image"
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
priority
/>
*/
// ===== 10. Server Component Example =====
/*
// app/product/[id]/page.tsx
import { CloudflareImage } from '@/components/CloudflareImage';
import { notFound } from 'next/navigation';
async function getProduct(id: string) {
// Fetch from your database
const product = await db.product.findUnique({
where: { id },
include: { images: true }
});
if (!product) return null;
return product;
}
export default async function ProductPage({
params
}: {
params: { id: string };
}) {
const product = await getProduct(params.id);
if (!product) notFound();
return (
<div className="max-w-6xl mx-auto p-8">
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<div>
<CloudflareImage
imageId={product.images[0].cloudflareId}
alt={product.name}
width={800}
height={800}
priority
/>
</div>
<div>
<h1 className="text-3xl font-bold">{product.name}</h1>
<p className="text-gray-600 mt-4">{product.description}</p>
</div>
</div>
<div className="mt-8 grid grid-cols-4 gap-4">
{product.images.slice(1).map((image: any) => (
<CloudflareImage
key={image.id}
imageId={image.cloudflareId}
alt={`${product.name} - Image ${image.id}`}
width={200}
height={200}
/>
))}
</div>
</div>
);
}
*/
templates/overlay-watermark.ts
/**
* Image Watermarking with Canvas API
*
* Complete implementation for adding watermarks and overlays to images
* before uploading to Cloudflare Images.
*
* Features:
* - Text watermarks (copyright notices)
* - Logo watermarks (PNG overlays)
* - Customizable position and opacity
* - Multiple watermark styles
* - Server-side (Node.js) and client-side (Browser) support
*
* Usage:
* 1. Add watermark to image file
* 2. Upload watermarked image to Cloudflare Images
*/
// ===== Browser Implementation =====
/**
* Add text watermark to image (Browser)
*
* @param imageFile - Original image file
* @param watermarkText - Text to display (e.g., "© 2025 Your Company")
* @param options - Watermark options
* @returns Watermarked image as Blob
*/
export async function addTextWatermark(
imageFile: File,
watermarkText: string,
options: TextWatermarkOptions = {}
): Promise<Blob> {
const {
position = 'bottom-right',
fontSize = 24,
fontFamily = 'Arial',
textColor = '#FFFFFF',
backgroundColor = 'rgba(0, 0, 0, 0.5)',
padding = 10,
borderRadius = 3
} = options;
// Create canvas
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d')!;
// Load image
const img = await createImageBitmap(imageFile);
canvas.width = img.width;
canvas.height = img.height;
// Draw original image
ctx.drawImage(img, 0, 0);
// Set font
ctx.font = `bold ${fontSize}px ${fontFamily}`;
const textMetrics = ctx.measureText(watermarkText);
const textWidth = textMetrics.width;
const textHeight = fontSize;
// Calculate position
const { x, y } = calculatePosition(
canvas.width,
canvas.height,
textWidth + padding * 2,
textHeight + padding * 2,
position
);
// Draw background
ctx.fillStyle = backgroundColor;
if (borderRadius > 0) {
roundRect(ctx, x, y, textWidth + padding * 2, textHeight + padding * 2, borderRadius);
ctx.fill();
} else {
ctx.fillRect(x, y, textWidth + padding * 2, textHeight + padding * 2);
}
// Draw text
ctx.fillStyle = textColor;
ctx.textBaseline = 'top';
ctx.fillText(watermarkText, x + padding, y + padding);
// Convert to Blob
return new Promise((resolve) => {
canvas.toBlob((blob) => resolve(blob!), 'image/jpeg', 0.9);
});
}
/**
* Add logo watermark to image (Browser)
*
* @param imageFile - Original image file
* @param logoUrl - URL or data URL of logo image
* @param options - Watermark options
* @returns Watermarked image as Blob
*/
export async function addLogoWatermark(
imageFile: File,
logoUrl: string,
options: LogoWatermarkOptions = {}
): Promise<Blob> {
const {
position = 'bottom-right',
size = 0.15, // Logo size as percentage of image width (15%)
opacity = 0.5,
padding = 20
} = options;
// Create canvas
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d')!;
// Load main image
const img = await createImageBitmap(imageFile);
canvas.width = img.width;
canvas.height = img.height;
// Draw original image
ctx.drawImage(img, 0, 0);
// Load logo
const logo = new Image();
logo.src = logoUrl;
await logo.decode();
// Calculate logo dimensions
const logoWidth = canvas.width * size;
const logoHeight = (logo.height / logo.width) * logoWidth;
// Calculate position
const { x, y } = calculatePosition(
canvas.width,
canvas.height,
logoWidth,
logoHeight,
position,
padding
);
// Draw logo with opacity
ctx.globalAlpha = opacity;
ctx.drawImage(logo, x, y, logoWidth, logoHeight);
ctx.globalAlpha = 1.0;
// Convert to Blob
return new Promise((resolve) => {
canvas.toBlob((blob) => resolve(blob!), 'image/jpeg', 0.9);
});
}
/**
* Add tiled watermark pattern (Browser)
*
* @param imageFile - Original image file
* @param watermarkText - Text to tile across image
* @param options - Watermark options
* @returns Watermarked image as Blob
*/
export async function addTiledWatermark(
imageFile: File,
watermarkText: string,
options: TiledWatermarkOptions = {}
): Promise<Blob> {
const {
fontSize = 48,
opacity = 0.15,
angle = -45, // Diagonal
spacing = 200
} = options;
// Create canvas
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d')!;
// Load image
const img = await createImageBitmap(imageFile);
canvas.width = img.width;
canvas.height = img.height;
// Draw original image
ctx.drawImage(img, 0, 0);
// Configure watermark style
ctx.font = `bold ${fontSize}px Arial`;
ctx.fillStyle = '#FFFFFF';
ctx.globalAlpha = opacity;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
// Calculate grid
const angleRad = (angle * Math.PI) / 180;
const diagonal = Math.sqrt(canvas.width ** 2 + canvas.height ** 2);
// Draw tiled watermark
for (let x = -diagonal; x < diagonal * 2; x += spacing) {
for (let y = -diagonal; y < diagonal * 2; y += spacing) {
ctx.save();
ctx.translate(x, y);
ctx.rotate(angleRad);
ctx.fillText(watermarkText, 0, 0);
ctx.restore();
}
}
ctx.globalAlpha = 1.0;
// Convert to Blob
return new Promise((resolve) => {
canvas.toBlob((blob) => resolve(blob!), 'image/jpeg', 0.9);
});
}
// ===== Helper Functions =====
interface Position {
x: number;
y: number;
}
type PositionType =
| 'top-left'
| 'top-center'
| 'top-right'
| 'center-left'
| 'center'
| 'center-right'
| 'bottom-left'
| 'bottom-center'
| 'bottom-right';
function calculatePosition(
canvasWidth: number,
canvasHeight: number,
elementWidth: number,
elementHeight: number,
position: PositionType,
padding: number = 10
): Position {
const positions: Record<PositionType, Position> = {
'top-left': {
x: padding,
y: padding
},
'top-center': {
x: (canvasWidth - elementWidth) / 2,
y: padding
},
'top-right': {
x: canvasWidth - elementWidth - padding,
y: padding
},
'center-left': {
x: padding,
y: (canvasHeight - elementHeight) / 2
},
center: {
x: (canvasWidth - elementWidth) / 2,
y: (canvasHeight - elementHeight) / 2
},
'center-right': {
x: canvasWidth - elementWidth - padding,
y: (canvasHeight - elementHeight) / 2
},
'bottom-left': {
x: padding,
y: canvasHeight - elementHeight - padding
},
'bottom-center': {
x: (canvasWidth - elementWidth) / 2,
y: canvasHeight - elementHeight - padding
},
'bottom-right': {
x: canvasWidth - elementWidth - padding,
y: canvasHeight - elementHeight - padding
}
};
return positions[position];
}
function roundRect(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
width: number,
height: number,
radius: number
): void {
ctx.beginPath();
ctx.moveTo(x + radius, y);
ctx.lineTo(x + width - radius, y);
ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
ctx.lineTo(x + width, y + height - radius);
ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
ctx.lineTo(x + radius, y + height);
ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
ctx.lineTo(x, y + radius);
ctx.quadraticCurveTo(x, y, x + radius, y);
ctx.closePath();
}
// ===== TypeScript Interfaces =====
interface TextWatermarkOptions {
position?: PositionType;
fontSize?: number;
fontFamily?: string;
textColor?: string;
backgroundColor?: string;
padding?: number;
borderRadius?: number;
}
interface LogoWatermarkOptions {
position?: PositionType;
size?: number; // 0-1 (percentage of image width)
opacity?: number; // 0-1
padding?: number;
}
interface TiledWatermarkOptions {
fontSize?: number;
opacity?: number; // 0-1
angle?: number; // Rotation in degrees
spacing?: number; // Distance between watermarks
}
// ===== Upload to Cloudflare Images =====
/**
* Upload watermarked image to Cloudflare Images
*
* @param watermarkedBlob - Watermarked image blob
* @param filename - Original filename
* @param accountId - Cloudflare account ID
* @param apiToken - Cloudflare API token
* @returns Upload result
*/
export async function uploadWatermarkedImage(
watermarkedBlob: Blob,
filename: string,
accountId: string,
apiToken: string
): Promise<{ success: boolean; imageId?: string; error?: string }> {
try {
const formData = new FormData();
formData.append('file', watermarkedBlob, filename);
const response = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${accountId}/images/v1`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${apiToken}`
},
body: formData
}
);
const result = await response.json();
if (result.success) {
return {
success: true,
imageId: result.result.id
};
} else {
return {
success: false,
error: 'Upload failed'
};
}
} catch (error) {
console.error('Upload error:', error);
return {
success: false,
error: 'Internal error'
};
}
}
// ===== Complete Workflow Example =====
/**
* Complete workflow: Add watermark and upload to Cloudflare Images
*
* @example
* const file = fileInput.files[0];
* const result = await watermarkAndUpload(file, {
* watermarkType: 'text',
* watermarkText: '© 2025 Your Company',
* accountId: 'your-account-id',
* apiToken: 'your-api-token'
* });
*
* if (result.success) {
* console.log('Image uploaded:', result.imageId);
* }
*/
export async function watermarkAndUpload(
imageFile: File,
options: WatermarkAndUploadOptions
): Promise<{ success: boolean; imageId?: string; error?: string }> {
try {
let watermarkedBlob: Blob;
// Add watermark based on type
switch (options.watermarkType) {
case 'text':
watermarkedBlob = await addTextWatermark(
imageFile,
options.watermarkText || '© Your Company',
options.textOptions
);
break;
case 'logo':
if (!options.logoUrl) {
return { success: false, error: 'Logo URL required' };
}
watermarkedBlob = await addLogoWatermark(
imageFile,
options.logoUrl,
options.logoOptions
);
break;
case 'tiled':
watermarkedBlob = await addTiledWatermark(
imageFile,
options.watermarkText || '© Your Company',
options.tiledOptions
);
break;
default:
return { success: false, error: 'Invalid watermark type' };
}
// Upload to Cloudflare Images
return await uploadWatermarkedImage(
watermarkedBlob,
imageFile.name,
options.accountId,
options.apiToken
);
} catch (error) {
console.error('Watermark and upload error:', error);
return { success: false, error: 'Processing failed' };
}
}
interface WatermarkAndUploadOptions {
watermarkType: 'text' | 'logo' | 'tiled';
watermarkText?: string;
logoUrl?: string;
textOptions?: TextWatermarkOptions;
logoOptions?: LogoWatermarkOptions;
tiledOptions?: TiledWatermarkOptions;
accountId: string;
apiToken: string;
}
// ===== Usage Examples =====
/*
// Example 1: Text Watermark (Bottom-Right)
const file = fileInput.files[0];
const watermarked = await addTextWatermark(file, '© 2025 Your Company', {
position: 'bottom-right',
fontSize: 20,
textColor: '#FFFFFF',
backgroundColor: 'rgba(0, 0, 0, 0.6)',
padding: 10,
borderRadius: 5
});
// Example 2: Logo Watermark (Bottom-Right)
const watermarked = await addLogoWatermark(file, '/logo.png', {
position: 'bottom-right',
size: 0.15,
opacity: 0.5,
padding: 20
});
// Example 3: Tiled Watermark
const watermarked = await addTiledWatermark(file, 'CONFIDENTIAL', {
fontSize: 60,
opacity: 0.1,
angle: -45,
spacing: 250
});
// Example 4: Complete Workflow
const result = await watermarkAndUpload(file, {
watermarkType: 'text',
watermarkText: '© 2025 Your Company. All Rights Reserved.',
textOptions: {
position: 'bottom-right',
fontSize: 18
},
accountId: process.env.CF_ACCOUNT_ID,
apiToken: process.env.CF_API_TOKEN
});
if (result.success) {
console.log('Upload successful!', result.imageId);
}
*/
// ===== Server-Side Implementation (Node.js with Sharp) =====
/*
import sharp from 'sharp';
import { readFile } from 'fs/promises';
// Add text watermark using Sharp (Node.js)
async function addTextWatermarkServer(
imagePath: string,
watermarkText: string,
outputPath: string
): Promise<void> {
const image = sharp(imagePath);
const metadata = await image.metadata();
// Create SVG text overlay
const svgWatermark = `
<svg width="${metadata.width}" height="${metadata.height}">
<style>
.watermark {
fill: white;
font-size: 24px;
font-weight: bold;
font-family: Arial;
}
</style>
<rect x="${metadata.width! - 300}" y="${metadata.height! - 50}"
width="280" height="40" rx="5" fill="rgba(0,0,0,0.5)"/>
<text x="${metadata.width! - 160}" y="${metadata.height! - 20}"
text-anchor="middle" class="watermark">
${watermarkText}
</text>
</svg>
`;
await image
.composite([
{
input: Buffer.from(svgWatermark),
top: 0,
left: 0
}
])
.toFile(outputPath);
}
// Add logo watermark using Sharp (Node.js)
async function addLogoWatermarkServer(
imagePath: string,
logoPath: string,
outputPath: string
): Promise<void> {
const image = sharp(imagePath);
const metadata = await image.metadata();
const logo = await sharp(logoPath)
.resize({ width: Math.floor(metadata.width! * 0.15) })
.toBuffer();
await image
.composite([
{
input: logo,
gravity: 'southeast',
blend: 'over'
}
])
.toFile(outputPath);
}
*/
templates/package.json
{
"name": "cloudflare-images-example",
"version": "1.0.0",
"description": "Cloudflare Images examples and templates",
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"tail": "wrangler tail"
},
"dependencies": {},
"devDependencies": {
"@cloudflare/workers-types": "^4.20260408.0",
"typescript": "^5.9.3",
"wrangler": "^4.81.0"
}
}
templates/remix-integration.tsx
/**
* Remix + Cloudflare Images Integration
*
* Complete integration for Remix with Cloudflare Images.
* Works with Remix loaders, actions, and components.
*
* Features:
* - Reusable CloudflareImage component
* - Server-side upload action
* - Image gallery with loader
* - TypeScript support
* - Progressive enhancement
*
* Setup:
* 1. Set environment variables
* 2. Copy components and routes to your project
* 3. Configure database (optional)
*/
// ===== 1. .env =====
/*
# Cloudflare Images Configuration
CF_ACCOUNT_ID=your_account_id
CF_API_TOKEN=your_api_token
CF_ACCOUNT_HASH=your_account_hash
# Public (exposed to browser)
PUBLIC_CF_IMAGES_DOMAIN=images.yourdomain.com
# OR
PUBLIC_CF_ACCOUNT_HASH=your_account_hash
*/
// ===== 2. app/components/CloudflareImage.tsx =====
interface CloudflareImageProps {
/**
* Cloudflare Images ID
*/
imageId: string;
/**
* Variant name (optional)
* Default: 'public'
*/
variant?: string;
/**
* Image width for transformation
*/
width?: number;
/**
* Image quality (1-100)
* Default: 85
*/
quality?: number;
/**
* Image format
* Default: 'auto' (WebP/AVIF auto-detection)
*/
format?: 'auto' | 'webp' | 'avif' | 'jpeg' | 'png';
/**
* Alt text for accessibility
*/
alt: string;
/**
* Additional CSS classes
*/
className?: string;
/**
* Loading strategy
*/
loading?: 'lazy' | 'eager';
}
/**
* CloudflareImage Component
*
* Reusable component for displaying Cloudflare Images in Remix.
*
* @example
* <CloudflareImage
* imageId="2cdc28f0-017a-49c4-9ed7-87056c83901"
* alt="Product photo"
* width={800}
* quality={85}
* className="rounded-lg"
* />
*/
export function CloudflareImage({
imageId,
variant = 'public',
width,
quality = 85,
format = 'auto',
alt,
className,
loading = 'lazy'
}: CloudflareImageProps) {
const params = new URLSearchParams();
if (width) params.set('width', width.toString());
params.set('quality', quality.toString());
params.set('format', format);
const DOMAIN =
typeof window !== 'undefined'
? window.ENV?.PUBLIC_CF_IMAGES_DOMAIN
: process.env.PUBLIC_CF_IMAGES_DOMAIN;
const ACCOUNT_HASH =
typeof window !== 'undefined'
? window.ENV?.PUBLIC_CF_ACCOUNT_HASH
: process.env.PUBLIC_CF_ACCOUNT_HASH;
const transformations = params.toString() ? `?${params}` : '';
const imageUrl = DOMAIN
? `https://${DOMAIN}/${imageId}/${variant}${transformations}`
: `https://imagedelivery.net/${ACCOUNT_HASH}/${imageId}/${variant}${transformations}`;
return <img src={imageUrl} alt={alt} className={className} loading={loading} />;
}
// ===== 3. app/root.tsx (Environment Variables) =====
/*
import { json } from '@remix-run/node';
import { Links, LiveReload, Meta, Outlet, Scripts, ScrollRestoration, useLoaderData } from '@remix-run/react';
export async function loader() {
return json({
ENV: {
PUBLIC_CF_IMAGES_DOMAIN: process.env.PUBLIC_CF_IMAGES_DOMAIN,
PUBLIC_CF_ACCOUNT_HASH: process.env.PUBLIC_CF_ACCOUNT_HASH
}
});
}
export default function App() {
const { ENV } = useLoaderData<typeof loader>();
return (
<html lang="en">
<head>
<Meta />
<Links />
</head>
<body>
<Outlet />
<ScrollRestoration />
<script
dangerouslySetInnerHTML={{
__html: `window.ENV = ${JSON.stringify(ENV)}`
}}
/>
<Scripts />
<LiveReload />
</body>
</html>
);
}
*/
// ===== 4. app/routes/upload.tsx (Upload Route) =====
import { json, type ActionFunctionArgs, type LoaderFunctionArgs } from '@remix-run/node';
import { Form, useActionData, useNavigation } from '@remix-run/react';
import { CloudflareImage } from '~/components/CloudflareImage';
export async function action({ request }: ActionFunctionArgs) {
try {
// 1. Get file from form data
const formData = await request.formData();
const file = formData.get('file') as File;
if (!file || file.size === 0) {
return json({ error: 'No file selected' }, { status: 400 });
}
// 2. Validate file
const maxSize = 10 * 1024 * 1024; // 10 MB
if (file.size > maxSize) {
return json({ error: 'File too large (max 10MB)' }, { status: 400 });
}
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
if (!allowedTypes.includes(file.type)) {
return json({ error: 'Invalid file type' }, { status: 400 });
}
// 3. Upload to Cloudflare Images
const cloudflareFormData = new FormData();
cloudflareFormData.append('file', file);
const uploadResponse = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${process.env.CF_ACCOUNT_ID}/images/v1`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.CF_API_TOKEN}`
},
body: cloudflareFormData
}
);
const result = await uploadResponse.json();
if (!result.success) {
console.error('Cloudflare upload failed:', result.errors);
return json({ error: 'Upload failed' }, { status: 500 });
}
// 4. Optional: Save to database
// await db.images.create({
// data: {
// cloudflareId: result.result.id,
// filename: file.name,
// size: file.size
// }
// });
return json({
success: true,
imageId: result.result.id,
variants: result.result.variants,
filename: file.name
});
} catch (error) {
console.error('Upload error:', error);
return json({ error: 'Internal server error' }, { status: 500 });
}
}
export default function UploadPage() {
const actionData = useActionData<typeof action>();
const navigation = useNavigation();
const isUploading = navigation.state === 'submitting';
return (
<div className="max-w-2xl mx-auto p-8">
<h1 className="text-3xl font-bold mb-6">Upload Image</h1>
<Form method="post" encType="multipart/form-data" className="space-y-4">
<div>
<label htmlFor="file" className="block text-sm font-medium mb-2">
Select Image
</label>
<input
type="file"
id="file"
name="file"
accept="image/*"
required
className="block w-full border border-gray-300 rounded-lg p-2"
/>
</div>
<button
type="submit"
disabled={isUploading}
className="px-4 py-2 bg-blue-600 text-white rounded-lg disabled:opacity-50"
>
{isUploading ? 'Uploading...' : 'Upload Image'}
</button>
{actionData?.error && (
<p className="text-red-600">{actionData.error}</p>
)}
</Form>
{actionData?.success && (
<div className="mt-8">
<h2 className="text-xl font-semibold mb-4">Upload Successful!</h2>
<CloudflareImage
imageId={actionData.imageId}
alt={actionData.filename}
width={800}
className="rounded-lg shadow-lg"
/>
<div className="mt-4 space-y-1 text-sm text-gray-600">
<p>
<strong>Image ID:</strong> {actionData.imageId}
</p>
<p>
<strong>Filename:</strong> {actionData.filename}
</p>
</div>
</div>
)}
</div>
);
}
// ===== 5. app/routes/gallery.tsx (Gallery Route) =====
/*
import { json, type LoaderFunctionArgs } from '@remix-run/node';
import { useLoaderData } from '@remix-run/react';
import { CloudflareImage } from '~/components/CloudflareImage';
// Fetch images from your database
export async function loader({ request }: LoaderFunctionArgs) {
const images = await db.images.findMany({
select: {
id: true,
cloudflareId: true,
filename: true,
alt: true
},
orderBy: { createdAt: 'desc' },
take: 20
});
return json({ images });
}
export default function GalleryPage() {
const { images } = useLoaderData<typeof loader>();
return (
<div className="max-w-7xl mx-auto p-8">
<h1 className="text-3xl font-bold mb-8">Image Gallery</h1>
{images.length === 0 ? (
<p className="text-gray-600">No images uploaded yet.</p>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{images.map((image) => (
<div key={image.id} className="group">
<CloudflareImage
imageId={image.cloudflareId}
alt={image.alt || image.filename}
width={400}
className="rounded-lg shadow-lg group-hover:scale-105 transition-transform"
/>
<p className="mt-2 text-sm text-gray-600">{image.filename}</p>
</div>
))}
</div>
)}
</div>
);
}
*/
// ===== 6. app/routes/api.direct-upload.tsx (Direct Creator Upload) =====
/*
import { json, type ActionFunctionArgs } from '@remix-run/node';
export async function action({ request }: ActionFunctionArgs) {
try {
// Generate one-time upload URL
const response = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${process.env.CF_ACCOUNT_ID}/images/v2/direct_upload`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.CF_API_TOKEN}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
requireSignedURLs: false,
metadata: {
source: 'remix-app',
timestamp: new Date().toISOString()
}
})
}
);
const result = await response.json();
if (!result.success) {
return json({ error: 'Failed to generate upload URL' }, { status: 500 });
}
return json({
uploadURL: result.result.uploadURL,
id: result.result.id
});
} catch (error) {
console.error('Direct upload error:', error);
return json({ error: 'Internal server error' }, { status: 500 });
}
}
*/
// ===== 7. app/routes/product.$id.tsx (Product Detail with Images) =====
/*
import { json, type LoaderFunctionArgs } from '@remix-run/node';
import { useLoaderData } from '@remix-run/react';
import { CloudflareImage } from '~/components/CloudflareImage';
export async function loader({ params }: LoaderFunctionArgs) {
const product = await db.product.findUnique({
where: { id: params.id },
include: {
images: {
orderBy: { order: 'asc' }
}
}
});
if (!product) {
throw new Response('Not Found', { status: 404 });
}
return json({ product });
}
export default function ProductPage() {
const { product } = useLoaderData<typeof loader>();
return (
<div className="max-w-6xl mx-auto p-8">
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<div>
{product.images.length > 0 && (
<CloudflareImage
imageId={product.images[0].cloudflareId}
alt={product.name}
width={800}
className="rounded-lg shadow-lg"
/>
)}
{product.images.length > 1 && (
<div className="mt-4 grid grid-cols-4 gap-2">
{product.images.slice(1).map((image) => (
<CloudflareImage
key={image.id}
imageId={image.cloudflareId}
alt={product.name}
width={200}
className="rounded cursor-pointer hover:opacity-75"
/>
))}
</div>
)}
</div>
<div>
<h1 className="text-3xl font-bold">{product.name}</h1>
<p className="text-gray-600 mt-4">{product.description}</p>
<p className="text-2xl font-bold mt-6">${product.price}</p>
</div>
</div>
</div>
);
}
*/
// ===== 8. TypeScript Types =====
/*
// types/cloudflare-images.ts
export interface CloudflareImage {
id: string;
cloudflareId: string;
filename: string;
alt: string | null;
size: number;
createdAt: Date;
}
export interface CloudflareUploadResult {
success: boolean;
result: {
id: string;
filename: string;
uploaded: string;
requireSignedURLs: boolean;
variants: string[];
};
errors?: Array<{ code: number; message: string }>;
}
export interface DirectUploadResult {
uploadURL: string;
id: string;
}
*/
// ===== 9. Database Schema (Prisma) =====
/*
// prisma/schema.prisma
model Image {
id String @id @default(cuid())
cloudflareId String @unique
filename String
alt String?
size Int
userId String?
user User? @relation(fields: [userId], references: [id])
createdAt DateTime @default(now())
@@index([userId])
@@index([createdAt])
}
*/
// ===== 10. Utility Functions =====
/*
// app/utils/cloudflare-images.server.ts
export async function uploadToCloudflare(
file: File,
metadata?: Record<string, string>
): Promise<{ success: boolean; imageId?: string; error?: string }> {
try {
const formData = new FormData();
formData.append('file', file);
if (metadata) {
formData.append('metadata', JSON.stringify(metadata));
}
const response = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${process.env.CF_ACCOUNT_ID}/images/v1`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.CF_API_TOKEN}`
},
body: formData
}
);
const result = await response.json();
if (result.success) {
return { success: true, imageId: result.result.id };
} else {
return { success: false, error: 'Upload failed' };
}
} catch (error) {
console.error('Upload error:', error);
return { success: false, error: 'Internal error' };
}
}
export async function deleteFromCloudflare(imageId: string): Promise<boolean> {
try {
const response = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${process.env.CF_ACCOUNT_ID}/images/v1/${imageId}`,
{
method: 'DELETE',
headers: {
'Authorization': `Bearer ${process.env.CF_API_TOKEN}`
}
}
);
const result = await response.json();
return result.success;
} catch (error) {
console.error('Delete error:', error);
return false;
}
}
*/
// ===== 11. Progressive Enhancement Example =====
// Example: app/routes/upload-progressive.tsx (shown as line comments so the
// JSX comment "{/* ... */}" inside doesn't prematurely close a block comment):
//
// import { Form, useActionData } from '@remix-run/react';
//
// export default function UploadProgressivePage() {
// const actionData = useActionData<typeof action>();
//
// return (
// <Form method="post" encType="multipart/form-data">
// <input type="file" name="file" required />
// <button type="submit">Upload</button>
//
// {actionData?.error && <p>{actionData.error}</p>}
// {actionData?.success && <p>Success!</p>}
//
// {/* Works without JavaScript */}
// <noscript>
// <p>Form works without JavaScript enabled!</p>
// </noscript>
// </Form>
// );
// }
templates/responsive-images-srcset.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Responsive Images with Cloudflare Images</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, -apple-system, sans-serif;
padding: 2rem;
max-width: 1200px;
margin: 0 auto;
}
h1 { margin-bottom: 2rem; }
section { margin-bottom: 3rem; }
h2 { margin-bottom: 1rem; color: #333; }
p { margin-bottom: 1rem; color: #666; }
img { max-width: 100%; height: auto; display: block; border-radius: 8px; }
.code-block { background: #f5f5f5; padding: 1rem; border-radius: 6px; margin-top: 0.5rem; overflow-x: auto; }
</style>
</head>
<body>
<h1>Responsive Images with Cloudflare Images</h1>
<!-- Example 1: srcset with named variants -->
<section>
<h2>1. Using Named Variants</h2>
<p>Serve different image sizes based on viewport width using predefined variants.</p>
<img
srcset="
https://imagedelivery.net/YOUR_HASH/IMAGE_ID/mobile 480w,
https://imagedelivery.net/YOUR_HASH/IMAGE_ID/tablet 768w,
https://imagedelivery.net/YOUR_HASH/IMAGE_ID/desktop 1920w
"
sizes="(max-width: 480px) 480px, (max-width: 768px) 768px, 1920px"
src="https://imagedelivery.net/YOUR_HASH/IMAGE_ID/desktop"
alt="Responsive image with named variants"
loading="lazy"
/>
<div class="code-block">
<code>
<img
srcset="
https://imagedelivery.net/YOUR_HASH/IMAGE_ID/mobile 480w,
https://imagedelivery.net/YOUR_HASH/IMAGE_ID/tablet 768w,
https://imagedelivery.net/YOUR_HASH/IMAGE_ID/desktop 1920w
"
sizes="(max-width: 480px) 480px, (max-width: 768px) 768px, 1920px"
src="https://imagedelivery.net/YOUR_HASH/IMAGE_ID/desktop"
alt="Responsive image"
loading="lazy"
/>
</code>
</div>
</section>
<!-- Example 2: srcset with flexible variants -->
<section>
<h2>2. Using Flexible Variants</h2>
<p>Dynamic transformations with format=auto for optimal WebP/AVIF delivery.</p>
<img
srcset="
https://imagedelivery.net/YOUR_HASH/IMAGE_ID/w=480,f=auto 480w,
https://imagedelivery.net/YOUR_HASH/IMAGE_ID/w=768,f=auto 768w,
https://imagedelivery.net/YOUR_HASH/IMAGE_ID/w=1920,f=auto 1920w
"
sizes="(max-width: 480px) 480px, (max-width: 768px) 768px, 1920px"
src="https://imagedelivery.net/YOUR_HASH/IMAGE_ID/w=1920,f=auto"
alt="Responsive image with flexible variants"
loading="lazy"
/>
<div class="code-block">
<code>
<img
srcset="
https://imagedelivery.net/YOUR_HASH/IMAGE_ID/w=480,f=auto 480w,
https://imagedelivery.net/YOUR_HASH/IMAGE_ID/w=768,f=auto 768w,
https://imagedelivery.net/YOUR_HASH/IMAGE_ID/w=1920,f=auto 1920w
"
sizes="(max-width: 480px) 480px, (max-width: 768px) 768px, 1920px"
src="https://imagedelivery.net/YOUR_HASH/IMAGE_ID/w=1920,f=auto"
/>
</code>
</div>
</section>
<!-- Example 3: Art direction with picture element -->
<section>
<h2>3. Art Direction (Different Crops)</h2>
<p>Serve different image crops for mobile vs desktop (e.g., portrait on mobile, landscape on desktop).</p>
<picture>
<source
media="(max-width: 767px)"
srcset="https://imagedelivery.net/YOUR_HASH/IMAGE_ID/mobile-square"
/>
<source
media="(min-width: 768px)"
srcset="https://imagedelivery.net/YOUR_HASH/IMAGE_ID/desktop-wide"
/>
<img
src="https://imagedelivery.net/YOUR_HASH/IMAGE_ID/desktop-wide"
alt="Art directed image"
loading="lazy"
/>
</picture>
<div class="code-block">
<code>
<picture>
<source
media="(max-width: 767px)"
srcset="https://imagedelivery.net/YOUR_HASH/IMAGE_ID/mobile-square"
/>
<source
media="(min-width: 768px)"
srcset="https://imagedelivery.net/YOUR_HASH/IMAGE_ID/desktop-wide"
/>
<img
src="https://imagedelivery.net/YOUR_HASH/IMAGE_ID/desktop-wide"
alt="Art directed image"
/>
</picture>
</code>
</div>
</section>
<!-- Example 4: Retina displays -->
<section>
<h2>4. High-DPI (Retina) Displays</h2>
<p>Serve 2x images for high-resolution screens.</p>
<img
srcset="
https://imagedelivery.net/YOUR_HASH/IMAGE_ID/w=400,dpr=1,f=auto 1x,
https://imagedelivery.net/YOUR_HASH/IMAGE_ID/w=400,dpr=2,f=auto 2x
"
src="https://imagedelivery.net/YOUR_HASH/IMAGE_ID/w=400,f=auto"
alt="Retina-ready image"
loading="lazy"
/>
<div class="code-block">
<code>
<img
srcset="
https://imagedelivery.net/YOUR_HASH/IMAGE_ID/w=400,dpr=1,f=auto 1x,
https://imagedelivery.net/YOUR_HASH/IMAGE_ID/w=400,dpr=2,f=auto 2x
"
src="https://imagedelivery.net/YOUR_HASH/IMAGE_ID/w=400,f=auto"
/>
</code>
</div>
</section>
<!-- Example 5: Blur placeholder (LQIP) -->
<section>
<h2>5. Low-Quality Image Placeholder (LQIP)</h2>
<p>Load a tiny blurred placeholder first, then swap to full image.</p>
<img
id="lqip-image"
src="https://imagedelivery.net/YOUR_HASH/IMAGE_ID/w=50,q=10,blur=20,f=webp"
data-src="https://imagedelivery.net/YOUR_HASH/IMAGE_ID/w=1920,f=auto"
alt="Image with LQIP"
style="filter: blur(10px); transition: filter 0.3s;"
/>
<script>
const lqipImage = document.getElementById('lqip-image');
const fullImageURL = lqipImage.getAttribute('data-src');
// Load full-size image
const fullImage = new Image();
fullImage.src = fullImageURL;
fullImage.onload = () => {
lqipImage.src = fullImageURL;
lqipImage.style.filter = 'blur(0)';
};
</script>
<div class="code-block">
<code>
<img
src="https://imagedelivery.net/HASH/ID/w=50,q=10,blur=20,f=webp"
data-src="https://imagedelivery.net/HASH/ID/w=1920,f=auto"
alt="Image with LQIP"
/>
<script>
const img = document.querySelector('img');
const fullImg = new Image();
fullImg.src = img.getAttribute('data-src');
fullImg.onload = () => { img.src = fullImg.src; };
</script>
</code>
</div>
</section>
<!-- Example 6: URL transformations -->
<section>
<h2>6. Using URL Transformations (/cdn-cgi/image/)</h2>
<p>Transform ANY publicly accessible image (not just Cloudflare Images storage).</p>
<img
srcset="
/cdn-cgi/image/width=480,quality=85,format=auto/uploads/photo.jpg 480w,
/cdn-cgi/image/width=768,quality=85,format=auto/uploads/photo.jpg 768w,
/cdn-cgi/image/width=1920,quality=85,format=auto/uploads/photo.jpg 1920w
"
sizes="(max-width: 480px) 480px, (max-width: 768px) 768px, 1920px"
src="/cdn-cgi/image/width=1920,quality=85,format=auto/uploads/photo.jpg"
alt="Transformed image from origin"
loading="lazy"
/>
<div class="code-block">
<code>
<img
srcset="
/cdn-cgi/image/width=480,format=auto/uploads/photo.jpg 480w,
/cdn-cgi/image/width=768,format=auto/uploads/photo.jpg 768w,
/cdn-cgi/image/width=1920,format=auto/uploads/photo.jpg 1920w
"
sizes="(max-width: 480px) 480px, (max-width: 768px) 768px, 1920px"
/>
</code>
</div>
</section>
<!-- Tips -->
<section>
<h2>Best Practices</h2>
<ul style="color: #666; line-height: 1.8; padding-left: 1.5rem;">
<li>Always use <code>format=auto</code> for optimal WebP/AVIF delivery</li>
<li>Add <code>loading="lazy"</code> for images below the fold</li>
<li>Use <code>sizes</code> attribute to match your CSS layout</li>
<li>Provide descriptive <code>alt</code> text for accessibility</li>
<li>Consider art direction for different screen sizes (portrait vs landscape)</li>
<li>Use LQIP (Low-Quality Image Placeholder) for better perceived performance</li>
<li>Named variants: Best for consistent sizes and signed URLs</li>
<li>Flexible variants: Best for dynamic sizing (public images only)</li>
</ul>
</section>
</body>
</html>
templates/signed-urls-generation.ts
/**
* Cloudflare Images - Signed URLs Generation
*
* Generate time-limited, signed URLs for private images using HMAC-SHA256.
*
* URL format:
* https://imagedelivery.net/<ACCOUNT_HASH>/<IMAGE_ID>/<VARIANT>?exp=<EXPIRY>&sig=<SIGNATURE>
*/
interface Env {
IMAGES_ACCOUNT_HASH: string;
IMAGES_SIGNING_KEY: string; // From Dashboard → Images → Keys
}
/**
* Generate signed URL for private image
*/
export async function generateSignedURL(
imageId: string,
variant: string,
expirySeconds: number = 3600, // Default: 1 hour
env: Env
): Promise<string> {
// Calculate expiry timestamp
const now = Math.floor(Date.now() / 1000);
const expiry = now + expirySeconds;
// String to sign: {imageId}{variant}{expiry}
const stringToSign = `${imageId}${variant}${expiry}`;
// Generate HMAC-SHA256 signature
const encoder = new TextEncoder();
const keyData = encoder.encode(env.IMAGES_SIGNING_KEY);
const messageData = encoder.encode(stringToSign);
const cryptoKey = await crypto.subtle.importKey(
'raw',
keyData,
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const signature = await crypto.subtle.sign('HMAC', cryptoKey, messageData);
// Convert to hex string
const sig = Array.from(new Uint8Array(signature))
.map(b => b.toString(16).padStart(2, '0'))
.join('');
// Build signed URL
return `https://imagedelivery.net/${env.IMAGES_ACCOUNT_HASH}/${imageId}/${variant}?exp=${expiry}&sig=${sig}`;
}
/**
* Generate signed URL with absolute expiry time
*/
export async function generateSignedURLWithExpiry(
imageId: string,
variant: string,
expiryDate: Date,
env: Env
): Promise<string> {
const expiry = Math.floor(expiryDate.getTime() / 1000);
const stringToSign = `${imageId}${variant}${expiry}`;
const encoder = new TextEncoder();
const keyData = encoder.encode(env.IMAGES_SIGNING_KEY);
const messageData = encoder.encode(stringToSign);
const cryptoKey = await crypto.subtle.importKey(
'raw',
keyData,
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const signature = await crypto.subtle.sign('HMAC', cryptoKey, messageData);
const sig = Array.from(new Uint8Array(signature))
.map(b => b.toString(16).padStart(2, '0'))
.join('');
return `https://imagedelivery.net/${env.IMAGES_ACCOUNT_HASH}/${imageId}/${variant}?exp=${expiry}&sig=${sig}`;
}
/**
* Generate signed URLs for multiple variants
*/
export async function generateSignedURLsForVariants(
imageId: string,
variants: string[],
expirySeconds: number,
env: Env
): Promise<Record<string, string>> {
const urls: Record<string, string> = {};
for (const variant of variants) {
urls[variant] = await generateSignedURL(imageId, variant, expirySeconds, env);
}
return urls;
}
/**
* Example Cloudflare Worker
*/
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
// Generate signed URL: GET /api/signed-url/:imageId/:variant
if (request.method === 'GET' && url.pathname.startsWith('/api/signed-url/')) {
const parts = url.pathname.replace('/api/signed-url/', '').split('/');
const [imageId, variant] = parts;
if (!imageId || !variant) {
return Response.json({ error: 'Missing imageId or variant' }, { status: 400 });
}
// Parse expiry (default 1 hour)
const expirySeconds = parseInt(url.searchParams.get('expiry') || '3600');
try {
const signedURL = await generateSignedURL(imageId, variant, expirySeconds, env);
return Response.json({
signedURL,
expiresAt: new Date(Date.now() + expirySeconds * 1000).toISOString()
});
} catch (error) {
return Response.json(
{ error: error instanceof Error ? error.message : 'Failed to generate signed URL' },
{ status: 500 }
);
}
}
return Response.json({ error: 'Not found' }, { status: 404 });
}
};
/**
* Common expiry presets
*/
export const expiryPresets = {
fiveMinutes: 5 * 60,
fifteenMinutes: 15 * 60,
oneHour: 60 * 60,
oneDay: 24 * 60 * 60,
oneWeek: 7 * 24 * 60 * 60
};
/**
* Generate signed URL with preset expiry
*/
export async function generateSignedURLPreset(
imageId: string,
variant: string,
preset: keyof typeof expiryPresets,
env: Env
): Promise<string> {
return generateSignedURL(imageId, variant, expiryPresets[preset], env);
}
/**
* Verify if URL signature is valid (for reference, Cloudflare handles verification)
*/
export async function verifySignature(
imageId: string,
variant: string,
expiry: number,
signature: string,
env: Env
): Promise<boolean> {
// Check if expired
const now = Math.floor(Date.now() / 1000);
if (expiry < now) {
return false;
}
// Generate expected signature
const stringToSign = `${imageId}${variant}${expiry}`;
const encoder = new TextEncoder();
const keyData = encoder.encode(env.IMAGES_SIGNING_KEY);
const messageData = encoder.encode(stringToSign);
const cryptoKey = await crypto.subtle.importKey(
'raw',
keyData,
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const expectedSignature = await crypto.subtle.sign('HMAC', cryptoKey, messageData);
const expectedSig = Array.from(new Uint8Array(expectedSignature))
.map(b => b.toString(16).padStart(2, '0'))
.join('');
return expectedSig === signature;
}
/**
* Usage examples:
*
* ```typescript
* // Generate signed URL valid for 1 hour
* const signedURL = await generateSignedURL(
* 'image-id',
* 'public',
* 3600,
* env
* );
* // https://imagedelivery.net/{hash}/{id}/public?exp=1234567890&sig=abc123...
*
* // Generate with specific expiry date
* const expiryDate = new Date('2025-10-27T18:00:00Z');
* const signedURL = await generateSignedURLWithExpiry(
* 'image-id',
* 'public',
* expiryDate,
* env
* );
*
* // Generate for multiple variants
* const urls = await generateSignedURLsForVariants(
* 'image-id',
* ['thumbnail', 'medium', 'large'],
* 3600,
* env
* );
* // { thumbnail: 'https://...', medium: 'https://...', large: 'https://...' }
*
* // Use preset expiry
* const signedURL = await generateSignedURLPreset(
* 'image-id',
* 'public',
* 'oneDay',
* env
* );
* ```
*
* REQUIREMENTS:
* - Image must be uploaded with requireSignedURLs=true
* - Get signing key from Dashboard → Images → Keys
* - CANNOT use flexible variants with signed URLs (use named variants only)
*
* WHEN TO USE:
* - User profile photos (private until shared)
* - Paid content (time-limited access)
* - Temporary downloads
* - Secure image delivery
*/
templates/transform-via-url.ts
/**
* Cloudflare Images - Transform via URL
*
* Transform images using the special URL format:
* /cdn-cgi/image/<OPTIONS>/<SOURCE-IMAGE>
*
* Works on ANY publicly accessible image (not just Cloudflare Images storage).
*/
/**
* URL Transformation Examples
*/
// Basic resize
const thumbnailURL = '/cdn-cgi/image/width=300,height=300,fit=cover/uploads/photo.jpg';
// Responsive with auto format (WebP/AVIF)
const responsiveURL = '/cdn-cgi/image/width=800,quality=85,format=auto/uploads/hero.jpg';
// Smart crop to face
const avatarURL = '/cdn-cgi/image/width=200,height=200,gravity=face,fit=cover/uploads/profile.jpg';
// Blur effect
const blurredURL = '/cdn-cgi/image/blur=20,quality=50/uploads/background.jpg';
// Sharpen
const sharpenedURL = '/cdn-cgi/image/sharpen=3,quality=90/uploads/product.jpg';
// Rotate and flip
const rotatedURL = '/cdn-cgi/image/rotate=90,flip=h/uploads/document.jpg';
/**
* All available options (comma-separated)
*/
interface TransformOptions {
// Sizing
width?: number; // Max width in pixels (alias: w)
height?: number; // Max height in pixels (alias: h)
dpr?: number; // Device pixel ratio (1-3)
// Fit modes
fit?: 'scale-down' | 'contain' | 'cover' | 'crop' | 'pad';
// Quality
quality?: number; // 1-100 (alias: q)
compression?: 'fast' | 'lossless'; // WebP only
// Format
format?: 'auto' | 'avif' | 'webp' | 'jpeg' | 'baseline-jpeg' | 'json';
// 'auto' serves AVIF → WebP → original based on browser support
// Cropping
gravity?: 'auto' | 'face' | 'left' | 'right' | 'top' | 'bottom' | string; // Or 'XxY' coordinates
zoom?: number; // 0-1 for face cropping
trim?: number; // Remove border (pixels)
// Effects
blur?: number; // 1-250
sharpen?: number; // 0-10
brightness?: number; // 0-2 (1 = no change)
contrast?: number; // 0-2 (1 = no change)
gamma?: number; // 0-2 (1 = no change)
// Rotation
rotate?: 0 | 90 | 180 | 270;
flip?: 'h' | 'v' | 'hv'; // Horizontal, vertical, both
// Other
background?: string; // CSS color for transparency/padding
metadata?: 'none' | 'copyright' | 'keep'; // EXIF handling
anim?: boolean; // Preserve GIF/WebP animation (default: true)
}
/**
* Build transformation URL
*/
export function buildTransformURL(
imagePath: string,
options: Partial<TransformOptions>
): string {
const params: string[] = [];
// Sizing
if (options.width) params.push(`width=${options.width}`);
if (options.height) params.push(`height=${options.height}`);
if (options.dpr) params.push(`dpr=${options.dpr}`);
// Fit
if (options.fit) params.push(`fit=${options.fit}`);
// Quality
if (options.quality) params.push(`quality=${options.quality}`);
if (options.compression) params.push(`compression=${options.compression}`);
// Format
if (options.format) params.push(`format=${options.format}`);
// Cropping
if (options.gravity) params.push(`gravity=${options.gravity}`);
if (options.zoom) params.push(`zoom=${options.zoom}`);
if (options.trim) params.push(`trim=${options.trim}`);
// Effects
if (options.blur) params.push(`blur=${options.blur}`);
if (options.sharpen) params.push(`sharpen=${options.sharpen}`);
if (options.brightness) params.push(`brightness=${options.brightness}`);
if (options.contrast) params.push(`contrast=${options.contrast}`);
if (options.gamma) params.push(`gamma=${options.gamma}`);
// Rotation
if (options.rotate) params.push(`rotate=${options.rotate}`);
if (options.flip) params.push(`flip=${options.flip}`);
// Other
if (options.background) params.push(`background=${encodeURIComponent(options.background)}`);
if (options.metadata) params.push(`metadata=${options.metadata}`);
if (options.anim === false) params.push('anim=false');
return `/cdn-cgi/image/${params.join(',')}/${imagePath}`;
}
/**
* Example HTML generation
*/
export function generateResponsiveHTML(imagePath: string, alt: string): string {
return `
<img
srcset="${buildTransformURL(imagePath, { width: 480, format: 'auto' })} 480w,
${buildTransformURL(imagePath, { width: 768, format: 'auto' })} 768w,
${buildTransformURL(imagePath, { width: 1920, format: 'auto' })} 1920w"
sizes="(max-width: 480px) 480px, (max-width: 768px) 768px, 1920px"
src="${buildTransformURL(imagePath, { width: 1920, format: 'auto' })}"
alt="${alt}"
/>
`.trim();
}
/**
* Common presets
*/
export const presets = {
thumbnail: (path: string) => buildTransformURL(path, {
width: 300,
height: 300,
fit: 'cover',
quality: 85,
format: 'auto'
}),
avatar: (path: string) => buildTransformURL(path, {
width: 200,
height: 200,
fit: 'cover',
gravity: 'face',
quality: 90,
format: 'auto'
}),
hero: (path: string) => buildTransformURL(path, {
width: 1920,
height: 1080,
fit: 'cover',
quality: 85,
format: 'auto'
}),
blurPlaceholder: (path: string) => buildTransformURL(path, {
width: 50,
quality: 10,
blur: 20,
format: 'webp'
}),
productImage: (path: string) => buildTransformURL(path, {
width: 800,
height: 800,
fit: 'contain',
quality: 90,
sharpen: 2,
format: 'auto'
})
};
/**
* Usage examples:
*
* ```html
* <!-- Thumbnail -->
* <img src="/cdn-cgi/image/width=300,height=300,fit=cover,quality=85,format=auto/uploads/photo.jpg" />
*
* <!-- Smart crop to face -->
* <img src="/cdn-cgi/image/width=200,height=200,gravity=face,fit=cover/uploads/profile.jpg" />
*
* <!-- Blur effect for privacy -->
* <img src="/cdn-cgi/image/blur=20,quality=50/uploads/document.jpg" />
*
* <!-- Responsive with srcset -->
* <img
* srcset="/cdn-cgi/image/width=480,format=auto/uploads/hero.jpg 480w,
* /cdn-cgi/image/width=768,format=auto/uploads/hero.jpg 768w,
* /cdn-cgi/image/width=1920,format=auto/uploads/hero.jpg 1920w"
* sizes="(max-width: 480px) 480px, (max-width: 768px) 768px, 1920px"
* src="/cdn-cgi/image/width=1920,format=auto/uploads/hero.jpg"
* />
* ```
*
* With helper functions:
* ```typescript
* const url = buildTransformURL('uploads/photo.jpg', {
* width: 800,
* quality: 85,
* format: 'auto'
* });
*
* const html = generateResponsiveHTML('uploads/hero.jpg', 'Hero image');
*
* const thumbURL = presets.thumbnail('uploads/photo.jpg');
* ```
*
* IMPORTANT:
* - Must enable transformations on zone first (Dashboard → Images → Transformations)
* - Works on any publicly accessible image (not just Cloudflare Images storage)
* - Source image must use HTTPS (HTTP not supported)
* - URL-encode special characters in paths
*/
templates/transform-via-workers.ts
/**
* Cloudflare Images - Transform via Workers
*
* Use Workers to apply transformations programmatically with fetch() cf.image options.
*
* Benefits:
* - Custom URL schemes (hide storage location)
* - Preset names instead of pixel values
* - Content negotiation (serve optimal format)
* - Access control before serving
*/
interface Env {
// Optional: If storing originals in R2
IMAGES_BUCKET?: R2Bucket;
}
interface ImageTransformOptions {
width?: number;
height?: number;
fit?: 'scale-down' | 'contain' | 'cover' | 'crop' | 'pad';
quality?: number; // 1-100
format?: 'avif' | 'webp' | 'jpeg' | 'auto';
gravity?: 'auto' | 'face' | 'left' | 'right' | 'top' | 'bottom' | string;
blur?: number; // 1-250
sharpen?: number; // 0-10
rotate?: 0 | 90 | 180 | 270;
flip?: 'h' | 'v' | 'hv';
anim?: boolean;
metadata?: 'none' | 'copyright' | 'keep';
background?: string;
}
/**
* Example 1: Custom URL schemes with preset names
*/
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
// Custom URL: /images/thumbnail/photo.jpg
if (url.pathname.startsWith('/images/thumbnail/')) {
const imagePath = url.pathname.replace('/images/thumbnail/', '');
const imageURL = `https://storage.example.com/${imagePath}`;
return fetch(imageURL, {
cf: {
image: {
width: 300,
height: 300,
fit: 'cover',
quality: 85,
format: 'auto'
}
}
});
}
// Custom URL: /images/avatar/photo.jpg
if (url.pathname.startsWith('/images/avatar/')) {
const imagePath = url.pathname.replace('/images/avatar/', '');
const imageURL = `https://storage.example.com/${imagePath}`;
return fetch(imageURL, {
cf: {
image: {
width: 200,
height: 200,
fit: 'cover',
gravity: 'face', // Smart crop to face
quality: 90,
format: 'auto'
}
}
});
}
// Custom URL: /images/large/photo.jpg
if (url.pathname.startsWith('/images/large/')) {
const imagePath = url.pathname.replace('/images/large/', '');
const imageURL = `https://storage.example.com/${imagePath}`;
return fetch(imageURL, {
cf: {
image: {
width: 1920,
quality: 85,
format: 'auto'
}
}
});
}
return new Response('Not found', { status: 404 });
}
};
/**
* Example 2: Content negotiation (serve optimal format)
*/
function getOptimalFormat(request: Request): 'avif' | 'webp' | 'auto' {
const accept = request.headers.get('accept') || '';
if (/image\/avif/.test(accept)) {
return 'avif';
} else if (/image\/webp/.test(accept)) {
return 'webp';
}
return 'auto'; // Cloudflare decides
}
export const contentNegotiationWorker = {
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
const imagePath = url.pathname.replace('/images/', '');
const imageURL = `https://storage.example.com/${imagePath}`;
return fetch(imageURL, {
cf: {
image: {
width: 800,
quality: 85,
format: getOptimalFormat(request)
}
}
});
}
};
/**
* Example 3: Dynamic sizing based on query params
*/
export const dynamicSizeWorker = {
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
const imagePath = url.pathname.replace('/images/', '');
const imageURL = `https://storage.example.com/${imagePath}`;
// Parse query params: /images/photo.jpg?w=800&q=85
const width = parseInt(url.searchParams.get('w') || '1920');
const quality = parseInt(url.searchParams.get('q') || '85');
// Validate
const safeWidth = Math.min(Math.max(width, 100), 4000); // 100-4000px
const safeQuality = Math.min(Math.max(quality, 10), 100); // 10-100
return fetch(imageURL, {
cf: {
image: {
width: safeWidth,
quality: safeQuality,
format: 'auto'
}
}
});
}
};
/**
* Example 4: Access control before serving
*/
export const protectedImageWorker = {
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
// Check authentication (example)
const authHeader = request.headers.get('Authorization');
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return new Response('Unauthorized', { status: 401 });
}
// Verify token (simplified example)
const token = authHeader.replace('Bearer ', '');
if (token !== 'valid-token') {
return new Response('Forbidden', { status: 403 });
}
// Serve image after auth check
const imagePath = url.pathname.replace('/protected/', '');
const imageURL = `https://storage.example.com/${imagePath}`;
return fetch(imageURL, {
cf: {
image: {
width: 800,
quality: 85,
format: 'auto'
}
}
});
}
};
/**
* Example 5: R2 integration
*/
export const r2ImageWorker = {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const key = url.pathname.replace('/images/', '');
// Get image from R2
const object = await env.IMAGES_BUCKET?.get(key);
if (!object) {
return new Response('Image not found', { status: 404 });
}
// Transform and serve
return fetch(new Request(url.toString(), {
method: 'GET',
body: object.body
}), {
cf: {
image: {
width: 800,
quality: 85,
format: 'auto'
}
}
});
}
};
/**
* Example 6: Prevent transformation loops (error 9403)
*/
export const safeTransformWorker = {
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
// ✅ CORRECT: Fetch external origin
if (url.pathname.startsWith('/images/')) {
const imagePath = url.pathname.replace('/images/', '');
const originURL = `https://storage.example.com/${imagePath}`;
return fetch(originURL, {
cf: {
image: {
width: 800,
quality: 85
}
}
});
}
// ❌ WRONG: Don't fetch Worker's own URL (causes loop)
// return fetch(request, { cf: { image: { width: 800 } } }); // ERROR 9403
return new Response('Not found', { status: 404 });
}
};
/**
* Example 7: Error handling
*/
export const robustImageWorker = {
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
const imagePath = url.pathname.replace('/images/', '');
const imageURL = `https://storage.example.com/${imagePath}`;
try {
// Verify origin returns image (prevent error 9412)
const headResponse = await fetch(imageURL, { method: 'HEAD' });
const contentType = headResponse.headers.get('content-type');
if (!contentType?.startsWith('image/')) {
return new Response('Not an image', { status: 400 });
}
// Transform
const response = await fetch(imageURL, {
cf: {
image: {
width: 800,
quality: 85,
format: 'auto'
}
}
});
// Check for transformation errors
const cfResized = response.headers.get('Cf-Resized');
if (cfResized?.includes('err=')) {
console.error('Transformation error:', cfResized);
return new Response('Image transformation failed', { status: 502 });
}
return response;
} catch (error) {
console.error('Image fetch error:', error);
return new Response('Failed to fetch image', { status: 502 });
}
}
};
/**
* Helper: Build transform options
*/
export function buildTransformOptions(
preset: 'thumbnail' | 'avatar' | 'hero' | 'product',
overrides?: Partial<ImageTransformOptions>
): ImageTransformOptions {
const presets = {
thumbnail: { width: 300, height: 300, fit: 'cover' as const, quality: 85 },
avatar: { width: 200, height: 200, fit: 'cover' as const, gravity: 'face', quality: 90 },
hero: { width: 1920, height: 1080, fit: 'cover' as const, quality: 85 },
product: { width: 800, height: 800, fit: 'contain' as const, quality: 90, sharpen: 2 }
};
return {
...presets[preset],
format: 'auto',
...overrides
};
}
/**
* CRITICAL ERROR CODES:
*
* - 9401: Invalid cf.image options
* - 9402: Image too large or connection interrupted
* - 9403: Request loop (Worker fetching itself)
* - 9406/9419: Non-HTTPS URL or URL has spaces/unescaped Unicode
* - 9412: Origin returned non-image (e.g., HTML error page)
* - 9413: Image exceeds 100 megapixels
*
* Check 'Cf-Resized' header for error codes.
*/
templates/upload-api-basic.ts
/**
* Cloudflare Images - Basic Upload via API
*
* Uploads an image file to Cloudflare Images storage.
*
* Usage:
* const result = await uploadImageToCloudflare(file, {
* requireSignedURLs: false,
* metadata: { userId: '12345' }
* });
*/
interface Env {
IMAGES_ACCOUNT_ID: string;
IMAGES_API_TOKEN: string;
}
interface UploadOptions {
id?: string; // Custom ID (optional, auto-generated if not provided)
requireSignedURLs?: boolean; // true for private images
metadata?: Record<string, string>; // Max 1024 bytes, not visible to end users
}
interface CloudflareImagesResponse {
success: boolean;
result?: {
id: string;
filename: string;
uploaded: string;
requireSignedURLs: boolean;
variants: string[];
};
errors?: Array<{ code: number; message: string }>;
}
/**
* Upload image to Cloudflare Images
*/
export async function uploadImageToCloudflare(
file: File,
options: UploadOptions = {},
env: Env
): Promise<CloudflareImagesResponse> {
const formData = new FormData();
// Required: File to upload
formData.append('file', file);
// Optional: Custom ID (if not provided, auto-generated)
if (options.id) {
formData.append('id', options.id);
}
// Optional: Require signed URLs for private images
if (options.requireSignedURLs !== undefined) {
formData.append('requireSignedURLs', String(options.requireSignedURLs));
}
// Optional: Metadata (JSON object, max 1024 bytes)
if (options.metadata) {
formData.append('metadata', JSON.stringify(options.metadata));
}
// Upload to Cloudflare Images API
const response = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${env.IMAGES_ACCOUNT_ID}/images/v1`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${env.IMAGES_API_TOKEN}`
// Don't set Content-Type - FormData sets it automatically with boundary
},
body: formData
}
);
const result: CloudflareImagesResponse = await response.json();
if (!result.success) {
console.error('Upload failed:', result.errors);
throw new Error(`Upload failed: ${result.errors?.[0]?.message || 'Unknown error'}`);
}
return result;
}
/**
* Example Cloudflare Worker endpoint
*/
export default {
async fetch(request: Request, env: Env): Promise<Response> {
if (request.method === 'POST' && new URL(request.url).pathname === '/upload') {
try {
// Parse multipart/form-data from request
const formData = await request.formData();
const file = formData.get('image') as File;
if (!file) {
return Response.json({ error: 'No file provided' }, { status: 400 });
}
// Upload to Cloudflare Images
const result = await uploadImageToCloudflare(
file,
{
requireSignedURLs: false,
metadata: {
uploadedBy: 'worker',
timestamp: new Date().toISOString()
}
},
env
);
return Response.json({
success: true,
imageId: result.result?.id,
variants: result.result?.variants
});
} catch (error) {
return Response.json(
{ error: error instanceof Error ? error.message : 'Upload failed' },
{ status: 500 }
);
}
}
return Response.json({ error: 'Method not allowed' }, { status: 405 });
}
};
/**
* Example usage from another script:
*
* ```typescript
* const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
* const file = fileInput.files?.[0];
*
* if (file) {
* const result = await uploadImageToCloudflare(file, {
* requireSignedURLs: false,
* metadata: { source: 'user-upload' }
* }, env);
*
* console.log('Uploaded:', result.result?.id);
* console.log('Serve at:', result.result?.variants[0]);
* }
* ```
*/
templates/upload-via-url.ts
/**
* Cloudflare Images - Upload via URL
*
* Ingest images from external URLs without downloading first.
*
* Use cases:
* - Migrating images from another service
* - Ingesting user-provided URLs
* - Backing up images from external sources
*/
interface Env {
IMAGES_ACCOUNT_ID: string;
IMAGES_API_TOKEN: string;
}
interface UploadViaURLOptions {
url: string; // Image URL to ingest
id?: string; // Custom ID (optional)
requireSignedURLs?: boolean;
metadata?: Record<string, string>;
}
interface CloudflareImagesResponse {
success: boolean;
result?: {
id: string;
filename: string;
uploaded: string;
requireSignedURLs: boolean;
variants: string[];
};
errors?: Array<{ code: number; message: string }>;
}
/**
* Upload image from external URL
*/
export async function uploadImageViaURL(
options: UploadViaURLOptions,
env: Env
): Promise<CloudflareImagesResponse> {
const formData = new FormData();
// Required: URL to ingest
formData.append('url', options.url);
// Optional: Custom ID
if (options.id) {
formData.append('id', options.id);
}
// Optional: Require signed URLs
if (options.requireSignedURLs !== undefined) {
formData.append('requireSignedURLs', String(options.requireSignedURLs));
}
// Optional: Metadata
if (options.metadata) {
formData.append('metadata', JSON.stringify(options.metadata));
}
const response = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${env.IMAGES_ACCOUNT_ID}/images/v1`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${env.IMAGES_API_TOKEN}`
},
body: formData
}
);
const result: CloudflareImagesResponse = await response.json();
if (!result.success) {
console.error('Upload via URL failed:', result.errors);
throw new Error(`Upload via URL failed: ${result.errors?.[0]?.message || 'Unknown error'}`);
}
return result;
}
/**
* Example Cloudflare Worker
*/
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
// Endpoint: POST /ingest-image
if (request.method === 'POST' && url.pathname === '/ingest-image') {
try {
const body = await request.json<{ imageUrl: string }>();
if (!body.imageUrl) {
return Response.json({ error: 'imageUrl required' }, { status: 400 });
}
// Validate URL format
try {
new URL(body.imageUrl);
} catch {
return Response.json({ error: 'Invalid URL' }, { status: 400 });
}
// Upload from external URL
const result = await uploadImageViaURL(
{
url: body.imageUrl,
metadata: {
source: 'external',
ingestedAt: new Date().toISOString()
}
},
env
);
return Response.json({
success: true,
imageId: result.result?.id,
variants: result.result?.variants
});
} catch (error) {
return Response.json(
{ error: error instanceof Error ? error.message : 'Ingestion failed' },
{ status: 500 }
);
}
}
return Response.json({ error: 'Not found' }, { status: 404 });
}
};
/**
* Batch ingestion example
*/
export async function batchIngestImages(
imageUrls: string[],
env: Env
): Promise<Array<{ url: string; result?: CloudflareImagesResponse; error?: string }>> {
const results = await Promise.allSettled(
imageUrls.map(async (url) => {
return {
url,
result: await uploadImageViaURL({ url }, env)
};
})
);
return results.map((result, index) => {
if (result.status === 'fulfilled') {
return result.value;
} else {
return {
url: imageUrls[index],
error: result.reason instanceof Error ? result.reason.message : 'Unknown error'
};
}
});
}
/**
* Example with authentication for private origins
*/
export async function uploadFromPrivateURL(
imageUrl: string,
username: string,
password: string,
env: Env
): Promise<CloudflareImagesResponse> {
// Cloudflare supports HTTP Basic Auth in URL
const urlObj = new URL(imageUrl);
const authenticatedURL = `${urlObj.protocol}//${username}:${password}@${urlObj.host}${urlObj.pathname}${urlObj.search}`;
return uploadImageViaURL({ url: authenticatedURL }, env);
}
/**
* Usage examples:
*
* ```typescript
* // Single image
* const result = await uploadImageViaURL({
* url: 'https://example.com/photo.jpg',
* metadata: { source: 'migration' }
* }, env);
*
* // Batch ingestion
* const urls = [
* 'https://example.com/photo1.jpg',
* 'https://example.com/photo2.jpg',
* 'https://example.com/photo3.jpg'
* ];
* const results = await batchIngestImages(urls, env);
*
* // Private origin with auth
* const result = await uploadFromPrivateURL(
* 'https://private-storage.example.com/image.jpg',
* 'username',
* 'password',
* env
* );
* ```
*/
templates/variants-management.ts
/**
* Cloudflare Images - Variants Management
*
* Create, list, update, and delete image variants.
* Variants define predefined transformations for different use cases.
*/
interface Env {
IMAGES_ACCOUNT_ID: string;
IMAGES_API_TOKEN: string;
}
interface VariantOptions {
fit?: 'scale-down' | 'contain' | 'cover' | 'crop' | 'pad';
width?: number;
height?: number;
metadata?: 'none' | 'copyright' | 'keep';
}
interface Variant {
id: string;
options: VariantOptions;
neverRequireSignedURLs?: boolean;
}
/**
* Create a new variant
*/
export async function createVariant(
id: string,
options: VariantOptions,
neverRequireSignedURLs: boolean = false,
env: Env
): Promise<{ success: boolean; result?: Variant }> {
const response = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${env.IMAGES_ACCOUNT_ID}/images/v1/variants`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${env.IMAGES_API_TOKEN}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
id,
options,
neverRequireSignedURLs
})
}
);
return response.json();
}
/**
* List all variants
*/
export async function listVariants(
env: Env
): Promise<{ success: boolean; result?: { variants: Variant[] } }> {
const response = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${env.IMAGES_ACCOUNT_ID}/images/v1/variants`,
{
headers: {
'Authorization': `Bearer ${env.IMAGES_API_TOKEN}`
}
}
);
return response.json();
}
/**
* Get a specific variant
*/
export async function getVariant(
id: string,
env: Env
): Promise<{ success: boolean; result?: Variant }> {
const response = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${env.IMAGES_ACCOUNT_ID}/images/v1/variants/${id}`,
{
headers: {
'Authorization': `Bearer ${env.IMAGES_API_TOKEN}`
}
}
);
return response.json();
}
/**
* Update a variant
*/
export async function updateVariant(
id: string,
options: VariantOptions,
neverRequireSignedURLs?: boolean,
env: Env
): Promise<{ success: boolean; result?: Variant }> {
const body: Record<string, unknown> = { options };
if (neverRequireSignedURLs !== undefined) {
body.neverRequireSignedURLs = neverRequireSignedURLs;
}
const response = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${env.IMAGES_ACCOUNT_ID}/images/v1/variants/${id}`,
{
method: 'PATCH',
headers: {
'Authorization': `Bearer ${env.IMAGES_API_TOKEN}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(body)
}
);
return response.json();
}
/**
* Delete a variant
*/
export async function deleteVariant(
id: string,
env: Env
): Promise<{ success: boolean }> {
const response = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${env.IMAGES_ACCOUNT_ID}/images/v1/variants/${id}`,
{
method: 'DELETE',
headers: {
'Authorization': `Bearer ${env.IMAGES_API_TOKEN}`
}
}
);
return response.json();
}
/**
* Enable flexible variants (dynamic transformations)
*/
export async function enableFlexibleVariants(
enabled: boolean,
env: Env
): Promise<{ success: boolean }> {
const response = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${env.IMAGES_ACCOUNT_ID}/images/v1/config`,
{
method: 'PATCH',
headers: {
'Authorization': `Bearer ${env.IMAGES_API_TOKEN}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
flexible_variants: enabled
})
}
);
return response.json();
}
/**
* Example Worker endpoint
*/
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
// Create variant: POST /api/variants
if (request.method === 'POST' && url.pathname === '/api/variants') {
try {
const body = await request.json<{
id: string;
width?: number;
height?: number;
fit?: string;
}>();
const result = await createVariant(
body.id,
{
width: body.width,
height: body.height,
fit: body.fit as VariantOptions['fit'],
metadata: 'none'
},
false,
env
);
return Response.json(result);
} catch (error) {
return Response.json(
{ error: error instanceof Error ? error.message : 'Failed to create variant' },
{ status: 500 }
);
}
}
// List variants: GET /api/variants
if (request.method === 'GET' && url.pathname === '/api/variants') {
const result = await listVariants(env);
return Response.json(result);
}
return Response.json({ error: 'Not found' }, { status: 404 });
}
};
/**
* Common variant presets
*/
export async function setupCommonVariants(env: Env): Promise<void> {
// Thumbnail
await createVariant('thumbnail', {
width: 300,
height: 300,
fit: 'cover',
metadata: 'none'
}, false, env);
// Avatar
await createVariant('avatar', {
width: 200,
height: 200,
fit: 'cover',
metadata: 'none'
}, false, env);
// Small
await createVariant('small', {
width: 480,
fit: 'scale-down',
metadata: 'none'
}, false, env);
// Medium
await createVariant('medium', {
width: 768,
fit: 'scale-down',
metadata: 'none'
}, false, env);
// Large
await createVariant('large', {
width: 1920,
fit: 'scale-down',
metadata: 'none'
}, false, env);
// Hero (wide)
await createVariant('hero', {
width: 1920,
height: 1080,
fit: 'cover',
metadata: 'none'
}, false, env);
// Product (square)
await createVariant('product', {
width: 800,
height: 800,
fit: 'contain',
metadata: 'none'
}, false, env);
}
/**
* Usage examples:
*
* ```typescript
* // Create a variant
* await createVariant('thumbnail', {
* width: 300,
* height: 300,
* fit: 'cover',
* metadata: 'none'
* }, false, env);
*
* // List all variants
* const { result } = await listVariants(env);
* console.log(result?.variants);
*
* // Update a variant
* await updateVariant('thumbnail', {
* width: 350, // Changed from 300
* height: 350,
* fit: 'cover'
* }, undefined, env);
*
* // Delete a variant
* await deleteVariant('old-variant', env);
*
* // Enable flexible variants (dynamic transformations)
* await enableFlexibleVariants(true, env);
* // Now can use: /w=400,sharpen=3 in URLs
*
* // Use variant in image URL
* const imageURL = `https://imagedelivery.net/${accountHash}/${imageId}/thumbnail`;
* ```
*
* LIMITS:
* - Maximum 100 named variants per account
* - Flexible variants: unlimited dynamic transformations (but can't use with signed URLs)
*
* WHEN TO USE:
* - Named variants: Consistent sizes, private images (signed URLs), predictable URLs
* - Flexible variants: Dynamic sizing, public images only, rapid prototyping
*/
templates/webhook-handler.ts
/**
* Cloudflare Images Webhook Handler
*
* Complete webhook handler for processing Cloudflare Images upload notifications.
* Includes signature verification, event processing, and error handling.
*
* Features:
* - HMAC-SHA256 signature verification
* - Database integration (D1)
* - Queue integration for async processing
* - Comprehensive error handling
* - Logging and monitoring
*
* Setup:
* 1. Configure webhook URL in Cloudflare Images dashboard
* 2. Set WEBHOOK_SECRET in wrangler.jsonc
* 3. Configure D1 database binding
* 4. (Optional) Configure queue for async processing
*/
// ===== Types =====
interface ImageWebhook {
event: 'image.uploaded';
timestamp: string;
accountId: string;
image: {
id: string;
filename: string;
uploaded: string;
requireSignedURLs: boolean;
variants: string[];
metadata?: Record<string, string>;
};
}
interface Env {
// Secrets
WEBHOOK_SECRET: string;
// D1 Database
DB: D1Database;
// Queue (optional)
PROCESSING_QUEUE?: Queue;
// Analytics (optional)
ANALYTICS?: AnalyticsEngineDataset;
}
// ===== Main Handler =====
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
// CORS preflight
if (request.method === 'OPTIONS') {
return handleCORS();
}
// Only accept POST requests
if (request.method !== 'POST') {
return new Response('Method not allowed', { status: 405 });
}
try {
// 1. Verify signature
const signature = request.headers.get('X-Cloudflare-Signature');
if (!signature) {
console.error('Missing webhook signature');
return new Response('Unauthorized', { status: 401 });
}
const body = await request.text();
const isValid = await verifySignature(body, signature, env.WEBHOOK_SECRET);
if (!isValid) {
console.error('Invalid webhook signature');
return new Response('Unauthorized', { status: 401 });
}
// 2. Parse webhook payload
let webhook: ImageWebhook;
try {
webhook = JSON.parse(body);
} catch (error) {
console.error('Invalid JSON payload:', error);
return new Response('Bad Request', { status: 400 });
}
// 3. Validate payload structure
if (!webhook.event || !webhook.image?.id) {
console.error('Invalid webhook structure:', webhook);
return new Response('Bad Request', { status: 400 });
}
// 4. Process webhook (respond immediately, process async)
ctx.waitUntil(processWebhook(webhook, env));
// 5. Log analytics
if (env.ANALYTICS) {
ctx.waitUntil(
env.ANALYTICS.writeDataPoint({
blobs: [webhook.event, webhook.image.id],
doubles: [1],
indexes: [webhook.accountId]
})
);
}
// Return success immediately
return new Response(
JSON.stringify({
success: true,
message: 'Webhook received',
imageId: webhook.image.id
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' }
}
);
} catch (error) {
console.error('Webhook processing error:', error);
return new Response('Internal Server Error', { status: 500 });
}
}
};
// ===== Signature Verification =====
async function verifySignature(
body: string,
signature: string,
secret: string
): Promise<boolean> {
try {
const encoder = new TextEncoder();
// Import secret key
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['verify']
);
// Convert hex signature to ArrayBuffer
const signatureBuffer = hexToBuffer(signature);
const dataBuffer = encoder.encode(body);
// Verify signature
return await crypto.subtle.verify('HMAC', key, signatureBuffer, dataBuffer);
} catch (error) {
console.error('Signature verification error:', error);
return false;
}
}
function hexToBuffer(hex: string): ArrayBuffer {
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.substr(i, 2), 16);
}
return bytes.buffer;
}
// ===== Webhook Processing =====
async function processWebhook(webhook: ImageWebhook, env: Env): Promise<void> {
try {
console.log(`Processing webhook: ${webhook.event} for image ${webhook.image.id}`);
// Save to database
await saveToDatabase(webhook, env);
// Send to processing queue (if configured)
if (env.PROCESSING_QUEUE) {
await queueForProcessing(webhook, env);
}
console.log(`Successfully processed webhook for image ${webhook.image.id}`);
} catch (error) {
console.error('Error processing webhook:', error);
throw error;
}
}
async function saveToDatabase(webhook: ImageWebhook, env: Env): Promise<void> {
try {
await env.DB.prepare(
`INSERT INTO uploaded_images (
cloudflare_id,
filename,
uploaded_at,
user_id,
variants,
requires_signed_urls,
metadata,
created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)`
)
.bind(
webhook.image.id,
webhook.image.filename,
webhook.image.uploaded,
webhook.image.metadata?.userId || null,
JSON.stringify(webhook.image.variants),
webhook.image.requireSignedURLs ? 1 : 0,
webhook.image.metadata ? JSON.stringify(webhook.image.metadata) : null
)
.run();
console.log(`Saved image ${webhook.image.id} to database`);
} catch (error) {
console.error('Database save error:', error);
throw error;
}
}
async function queueForProcessing(webhook: ImageWebhook, env: Env): Promise<void> {
if (!env.PROCESSING_QUEUE) return;
try {
await env.PROCESSING_QUEUE.send({
action: 'process_uploaded_image',
imageId: webhook.image.id,
filename: webhook.image.filename,
timestamp: webhook.timestamp,
metadata: webhook.image.metadata
});
console.log(`Queued image ${webhook.image.id} for processing`);
} catch (error) {
console.error('Queue send error:', error);
// Don't throw - queuing is optional
}
}
// ===== CORS Handler =====
function handleCORS(): Response {
return new Response(null, {
status: 204,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, X-Cloudflare-Signature',
'Access-Control-Max-Age': '86400'
}
});
}
// ===== Database Schema =====
/*
SQL Schema for D1:
CREATE TABLE IF NOT EXISTS uploaded_images (
id INTEGER PRIMARY KEY AUTOINCREMENT,
cloudflare_id TEXT NOT NULL UNIQUE,
filename TEXT NOT NULL,
uploaded_at TEXT NOT NULL,
user_id TEXT,
variants TEXT NOT NULL,
requires_signed_urls INTEGER NOT NULL DEFAULT 0,
metadata TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
processed INTEGER NOT NULL DEFAULT 0,
processed_at TEXT
);
CREATE INDEX idx_cloudflare_id ON uploaded_images(cloudflare_id);
CREATE INDEX idx_user_id ON uploaded_images(user_id);
CREATE INDEX idx_uploaded_at ON uploaded_images(uploaded_at);
CREATE INDEX idx_processed ON uploaded_images(processed);
*/
// ===== Queue Consumer (Optional) =====
/*
If using Cloudflare Queues, add this consumer:
export default {
async queue(batch: MessageBatch<any>, env: Env): Promise<void> {
for (const message of batch.messages) {
try {
const { action, imageId, filename } = message.body;
if (action === 'process_uploaded_image') {
// Add your custom processing logic here
console.log(`Processing image: ${imageId}`);
// Example: Generate additional thumbnails, run moderation, etc.
// Mark as processed
await env.DB.prepare(
`UPDATE uploaded_images
SET processed = 1, processed_at = CURRENT_TIMESTAMP
WHERE cloudflare_id = ?`
).bind(imageId).run();
}
message.ack();
} catch (error) {
console.error('Queue processing error:', error);
message.retry();
}
}
}
};
*/
// ===== Testing =====
/*
Test webhook locally:
curl --request POST \
http://localhost:8787/webhooks \
--header "X-Cloudflare-Signature: test_signature" \
--header "Content-Type: application/json" \
--data '{
"event": "image.uploaded",
"timestamp": "2025-01-15T10:30:00Z",
"accountId": "test_account",
"image": {
"id": "test_image_id",
"filename": "test.jpg",
"uploaded": "2025-01-15T10:30:00Z",
"requireSignedURLs": false,
"variants": [
"https://imagedelivery.net/hash/test_image_id/public"
],
"metadata": {
"userId": "user_123",
"source": "profile_upload"
}
}
}'
*/
templates/wrangler-images-binding.jsonc
{
"name": "my-worker",
"main": "src/index.ts",
"compatibility_date": "2025-10-26",
// Cloudflare Images doesn't require explicit bindings in wrangler.jsonc
// Images API is accessed via fetch to api.cloudflare.com
// Image Transformations work automatically when enabled on your zone
// Example: If storing originals in R2 and using Images for transformations
"r2_buckets": [
{
"binding": "IMAGES_BUCKET",
"bucket_name": "original-images",
"preview_bucket_name": "original-images-preview"
}
],
// Environment variables for Images API
"vars": {
"IMAGES_ACCOUNT_ID": "your-account-id",
"IMAGES_ACCOUNT_HASH": "your-account-hash" // From Dashboard → Images → Developer Resources
},
// Secrets (set via: wrangler secret put IMAGES_API_TOKEN)
// IMAGES_API_TOKEN - API token with Cloudflare Images: Edit permission
// IMAGES_SIGNING_KEY - Key for signed URLs (optional, from Dashboard → Images → Keys)
// No explicit binding needed for:
// - Image Transformations (/cdn-cgi/image/...)
// - Workers fetch with cf.image options
// - Direct Creator Upload API
// Example worker routes
"routes": [
{
"pattern": "example.com/*",
"zone_name": "example.com"
}
]
}