Claude Code with Cloudinary: Media Pipelines Without Surprises
Why Cloudinary without CLAUDE.md drains budget and leaks security
Cloudinary is the default media CDN for applications that need image transformations, video processing, signed delivery, and an upload widget that works across browsers and platforms in 2026. The SDKs are mature, the URL transformation grammar is powerful, and a basic upload is three lines of code. The problem is that the three lines that work in development can cost five figures a month at scale, and Claude Code does not know which transformations are cache-friendly and which generate a new derived asset on every request.
The most common Claude defaults that hurt Cloudinary integrations: using unsigned upload presets from server code (when you could sign and lock down the upload), generating ad-hoc transformations on every page render instead of using named transformations (which destroys cache hit rates), shipping the API secret to the client by accident, fetching from http:// URLs instead of https://, omitting eager transformations on upload (which delays first delivery), skipping the upload signature on server-mediated uploads, and using the default delivery URL pattern instead of secure URLs with auth tokens for paid content.
This guide covers the CLAUDE.md configuration that locks Claude Code into Cloudinary's correct model: signed uploads from server actions, named transformations as the cache contract, eager generation at upload time, restricted upload presets, and the webhook setup that catches failed uploads before they reach production. If you are building a Next.js application, Claude Code with Next.js covers the route handler patterns Cloudinary calls land in. For uploads triggered from React components, Claude Code with React shows the component patterns that pair with the Cloudinary widget.
The Cloudinary CLAUDE.md template
The CLAUDE.md at your project root is read at the start of every Claude Code session. For a Cloudinary integration it needs to declare: the SDK versions for server and client, the environment variable names, the signing rule, the named transformation policy, the secure URL requirement, and the hard rules that prevent the most common failure modes.
# Cloudinary integration rules
## Stack
- cloudinary ^2.x (server SDK)
- @cloudinary/url-gen ^1.x (client URL builder)
- next-cloudinary ^6.x (Next.js component wrapper, if using Next.js)
- TypeScript 5.x strict
- Node.js 20.x
## Credentials (.env.local)
- CLOUDINARY_CLOUD_NAME (safe to expose to client)
- CLOUDINARY_API_KEY (safe to expose to client)
- CLOUDINARY_API_SECRET (SERVER ONLY, NEVER expose)
- NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME (client-accessible cloud name)
- NEXT_PUBLIC_CLOUDINARY_UPLOAD_PRESET (a restricted, signed preset)
## Project structure
- src/lib/cloudinary.ts , server SDK config
- src/lib/cloudinary-client.ts , @cloudinary/url-gen instance for URL building
- src/app/api/sign-upload/ , signature endpoint for direct-from-browser uploads
- src/app/api/webhooks/cloudinary/ , upload notification handler
## Server SDK setup
- src/lib/cloudinary.ts content:
import { v2 as cloudinary } from 'cloudinary';
cloudinary.config({
cloud_name: process.env.CLOUDINARY_CLOUD_NAME,
api_key: process.env.CLOUDINARY_API_KEY,
api_secret: process.env.CLOUDINARY_API_SECRET,
secure: true,
});
export default cloudinary;
- ALWAYS set secure: true, ALWAYS use HTTPS URLs
## Upload pattern (MANDATORY)
For server-mediated uploads (file already on your server):
cloudinary.uploader.upload(filePath, {
upload_preset: 'signed_secure',
folder: 'app-name/category',
resource_type: 'auto',
eager: [{ width: 800, height: 600, crop: 'fill', quality: 'auto', fetch_format: 'auto' }],
eager_async: false,
})
For direct-from-browser uploads:
- Server: generate signature with cloudinary.utils.api_sign_request()
- Client: POST to upload endpoint with signature, timestamp, api_key
- NEVER use unsigned presets for user-generated content
## Transformation rules
- ALWAYS use named transformations for production URLs (defined in dashboard)
- NEVER generate ad-hoc transformations on every render (cache miss every time)
- ALWAYS include f_auto and q_auto in any transformation chain
- ALWAYS use secure HTTPS URLs (https://res.cloudinary.com/...)
- Width and height are derivatives, set them on named transformations
## Hard rules
- NEVER expose CLOUDINARY_API_SECRET to the client (no NEXT_PUBLIC_ prefix)
- NEVER use unsigned upload presets for user uploads in production
- NEVER hardcode cloud_name when CLOUDINARY_CLOUD_NAME env var exists
- NEVER fetch from http:// (insecure delivery)
- NEVER generate one-off transformations server-side, use named transformations
- NEVER skip eager: [] on first-load critical images
- ALWAYS validate file size and type before passing to uploader
Four rules here matter most.
The named transformations rule is the biggest cost lever. Cloudinary charges per transformation generation. An ad-hoc URL like w_800,h_600,c_fill,q_80,f_auto/image.jpg generates a new derived asset the first time it is requested, then caches it on the CDN. The next time the same URL appears, it serves from cache (cheap). The next time a similar but slightly different URL appears (w_801 instead of w_800), it generates another derived asset (expensive). Named transformations enforce a fixed set of derivatives, so cache hit rate stays high and your transformation bill stays predictable.
The signed upload rule is a security boundary. Unsigned upload presets accept any upload from anywhere with no authentication. They are convenient for prototypes and a disaster for production. A user-discovered unsigned preset URL can be used to flood your cloud with arbitrary content, costing you bandwidth and storage. Signed presets require a signature computed with your API secret, which only your server has.
The API secret rule is the most common Cloudinary security incident. The cloud name and API key are designed to be public. The API secret is not. Claude often groups all three under a single NEXT_PUBLIC_ prefix when generating Next.js environment variables, which ships the secret in the client bundle. The CLAUDE.md rule blocks this.
The f_auto, q_auto rule is a free performance win. f_auto tells Cloudinary to deliver the best modern format the requesting browser supports (AVIF, WebP, JPEG). q_auto applies smart quality based on content (high quality for photos, lower quality for screenshots). Combining them typically reduces image bytes by 40 to 70 percent compared to a fixed JPEG quality.
Install and credential setup
Install the server and client SDKs:
npm i cloudinary @cloudinary/url-gen
For Next.js, also install the official integration:
npm i next-cloudinary
Add credentials to your environment:
# .env.local
CLOUDINARY_CLOUD_NAME=your-cloud-name
CLOUDINARY_API_KEY=123456789012345
CLOUDINARY_API_SECRET=AbCdEfGhIjKlMnOpQrStUvWxYz
NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME=your-cloud-name
NEXT_PUBLIC_CLOUDINARY_UPLOAD_PRESET=signed_secure_preset
Notice the deliberate split: CLOUDINARY_API_SECRET has no NEXT_PUBLIC_ prefix, so Next.js's build process refuses to bundle it into client code. The cloud name appears twice because it is read in both server and client contexts.
The server SDK config:
// src/lib/cloudinary.ts
import { v2 as cloudinary } from 'cloudinary';
if (!process.env.CLOUDINARY_API_SECRET) {
throw new Error('CLOUDINARY_API_SECRET is not set');
}
cloudinary.config({
cloud_name: process.env.CLOUDINARY_CLOUD_NAME,
api_key: process.env.CLOUDINARY_API_KEY,
api_secret: process.env.CLOUDINARY_API_SECRET,
secure: true,
});
export default cloudinary;
The client URL builder:
// src/lib/cloudinary-client.ts
import { Cloudinary } from '@cloudinary/url-gen';
export const cld = new Cloudinary({
cloud: {
cloudName: process.env.NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME,
},
url: {
secure: true,
},
});
Server-mediated upload
For files that already live on your server (e.g., a multipart form upload received by a Next.js route handler), call the uploader directly:
// src/app/api/upload/route.ts
import { NextRequest, NextResponse } from 'next/server';
import cloudinary from '@/lib/cloudinary';
const MAX_BYTES = 10 * 1024 * 1024; // 10 MB
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/avif'];
export async function POST(req: NextRequest) {
const formData = await req.formData();
const file = formData.get('file') as File | null;
if (!file) {
return NextResponse.json({ error: 'No file' }, { status: 400 });
}
if (file.size > MAX_BYTES) {
return NextResponse.json({ error: 'File too large' }, { status: 413 });
}
if (!ALLOWED_TYPES.includes(file.type)) {
return NextResponse.json({ error: 'Invalid type' }, { status: 415 });
}
const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const result = await new Promise<{ secure_url: string; public_id: string }>((resolve, reject) => {
cloudinary.uploader.upload_stream(
{
upload_preset: 'signed_secure',
folder: 'claudify/avatars',
resource_type: 'image',
eager: [
{ width: 200, height: 200, crop: 'fill', quality: 'auto', fetch_format: 'auto' },
{ width: 400, height: 400, crop: 'fill', quality: 'auto', fetch_format: 'auto' },
],
eager_async: false,
},
(err, res) => {
if (err || !res) return reject(err);
resolve(res);
},
).end(buffer);
});
return NextResponse.json({
url: result.secure_url,
publicId: result.public_id,
});
}
Two patterns this route follows that Claude omits without CLAUDE.md. First, it validates file size and MIME type before calling Cloudinary, which avoids paying for upload bandwidth on rejected files. Second, it specifies eager: [...] so Cloudinary generates the avatar derivatives synchronously at upload time. The first time the avatar appears in the app, the URL hits the CDN cache directly instead of triggering an on-the-fly transformation.
Direct-from-browser upload with signature
For uploads that should not pass through your server (large videos, multiple files), generate a signature on the server and POST directly from the browser to Cloudinary's upload endpoint.
The signature endpoint:
// src/app/api/sign-upload/route.ts
import { NextRequest, NextResponse } from 'next/server';
import cloudinary from '@/lib/cloudinary';
export async function POST(req: NextRequest) {
const session = await getSession(req); // your auth check
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const timestamp = Math.round(Date.now() / 1000);
const folder = `claudify/users/${session.userId}`;
const upload_preset = 'signed_secure';
const paramsToSign = {
timestamp,
folder,
upload_preset,
};
const signature = cloudinary.utils.api_sign_request(
paramsToSign,
process.env.CLOUDINARY_API_SECRET!,
);
return NextResponse.json({
signature,
timestamp,
folder,
upload_preset,
api_key: process.env.CLOUDINARY_API_KEY,
cloud_name: process.env.CLOUDINARY_CLOUD_NAME,
});
}
async function getSession(_req: NextRequest) {
// your auth provider session lookup
return { userId: 'user_123' };
}
The browser-side upload:
async function uploadFile(file: File) {
const signResponse = await fetch('/api/sign-upload', { method: 'POST' });
const { signature, timestamp, folder, upload_preset, api_key, cloud_name } = await signResponse.json();
const formData = new FormData();
formData.append('file', file);
formData.append('signature', signature);
formData.append('timestamp', String(timestamp));
formData.append('folder', folder);
formData.append('upload_preset', upload_preset);
formData.append('api_key', api_key);
const uploadResponse = await fetch(
`https://api.cloudinary.com/v1_1/${cloud_name}/auto/upload`,
{ method: 'POST', body: formData },
);
if (!uploadResponse.ok) {
throw new Error('Upload failed');
}
return uploadResponse.json();
}
Add a signed upload section to CLAUDE.md:
## Signed direct uploads
- Signature endpoint at src/app/api/sign-upload/route.ts
- Require authentication before generating signature
- Sign with cloudinary.utils.api_sign_request(params, API_SECRET)
- Browser uploads to https://api.cloudinary.com/v1_1/{cloud_name}/auto/upload
- POST body: file, signature, timestamp, folder, upload_preset, api_key
- Folder pattern: app-name/category/{userId} for per-user isolation
- Timestamp expires after 1 hour, regenerate signature for retry beyond that
- Upload preset MUST be configured as Signed in Cloudinary dashboard
Named transformations
A named transformation is a pre-configured transformation chain stored in your Cloudinary account. URLs reference the name instead of the parameters. This guarantees cache hits across requests and prevents accidental cost blowouts from per-request variations.
Set up named transformations in the dashboard under Settings -> Transformations. Common patterns:
| Name | Transformation | Use case |
|---|---|---|
t_avatar |
w_200,h_200,c_fill,g_face,r_max,q_auto,f_auto |
Round user avatars |
t_card_hero |
w_1200,h_630,c_fill,g_auto,q_auto,f_auto |
Open Graph cards |
t_thumbnail |
w_400,h_400,c_fill,g_auto,q_auto:low,f_auto |
List thumbnails |
t_blog_inline |
w_800,c_limit,q_auto,f_auto |
Inline blog images |
Delivering an image with a named transformation:
// src/lib/cloudinary-url.ts
import { cld } from '@/lib/cloudinary-client';
export function avatarUrl(publicId: string) {
return cld.image(publicId).namedTransformation('avatar').toURL();
}
export function cardHeroUrl(publicId: string) {
return cld.image(publicId).namedTransformation('card_hero').toURL();
}
Or hand-build the URL when you do not want the URL builder dependency:
export function avatarUrl(publicId: string) {
const cloud = process.env.NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME;
return `https://res.cloudinary.com/${cloud}/image/upload/t_avatar/${publicId}`;
}
Add a transformations section to CLAUDE.md:
## Transformations
- Use named transformations for all production URLs
- Named transformations are defined in Cloudinary dashboard, NOT in code
- URL pattern: https://res.cloudinary.com/{cloud}/image/upload/t_{name}/{publicId}
- Use @cloudinary/url-gen for typed URL building when chaining transformations
- For one-off transformations (admin previews, debug), inline is acceptable
- Always include q_auto and f_auto in every transformation
- Use g_face for portraits, g_auto for general subjects
Get Claudify. The bundled Cloudinary CLAUDE.md ships with a starter set of named transformations and a typed URL helper module pre-configured.
Next.js Image with Cloudinary
The next-cloudinary package provides a CldImage component that wraps Cloudinary URLs in Next.js's image optimization layer. It handles responsive sizing, lazy loading, and blur placeholders automatically.
// src/components/Avatar.tsx
import { CldImage } from 'next-cloudinary';
interface AvatarProps {
publicId: string;
alt: string;
}
export function Avatar({ publicId, alt }: AvatarProps) {
return (
<CldImage
src={publicId}
width={200}
height={200}
alt={alt}
crop="fill"
gravity="face"
quality="auto"
format="auto"
className="rounded-full"
/>
);
}
Pre-configured rules for CldImage in CLAUDE.md:
## CldImage rules (Next.js)
- Always set width and height (avoids CLS)
- Always set alt (a11y, even if decorative use alt="")
- Use crop="fill" + gravity="face" for portraits
- Use crop="fill" + gravity="auto" for general subjects
- Use crop="limit" for inline content images
- quality="auto" and format="auto" are defaults but include explicitly for clarity
- Wrap in Suspense or use loading="lazy" prop for non-critical images
Video uploads and transformations
Cloudinary handles video uploads identically to images, with the same transformation grammar. The two practical differences: videos can be eagerly transcoded to multiple bitrates and formats at upload, and video URLs use /video/upload/ instead of /image/upload/.
const result = await cloudinary.uploader.upload(filePath, {
resource_type: 'video',
folder: 'claudify/videos',
upload_preset: 'signed_secure',
eager: [
{ width: 640, height: 360, crop: 'fill', quality: 'auto', format: 'mp4' },
{ width: 1280, height: 720, crop: 'fill', quality: 'auto', format: 'mp4' },
{ width: 1920, height: 1080, crop: 'fill', quality: 'auto', format: 'mp4' },
],
eager_async: true,
eager_notification_url: 'https://yourdomain.com/api/webhooks/cloudinary',
});
For video, eager_async: true is almost always correct because the transcoding can take minutes for large files. The webhook fires when each derivative is ready, so your app can update state and reveal the video to users without blocking the upload response.
Add a video section to CLAUDE.md:
## Video uploads
- resource_type: 'video' on upload calls
- Use eager_async: true for video transformations (they take minutes)
- Set eager_notification_url to receive completion webhooks
- URL pattern: https://res.cloudinary.com/{cloud}/video/upload/{transformation}/{publicId}.mp4
- Set max_file_size in upload_preset to cap individual video size
- Use streaming profiles (sp_full_hd) instead of bitrate ladders for HLS delivery
Webhook handling for upload notifications
Cloudinary fires webhooks when async operations complete (eager transformations, moderation reviews, video transcoding). Verifying the webhook signature and handling the payload is how you keep your application state in sync with Cloudinary's processing.
// src/app/api/webhooks/cloudinary/route.ts
import { NextRequest, NextResponse } from 'next/server';
import crypto from 'crypto';
const apiSecret = process.env.CLOUDINARY_API_SECRET!;
export async function POST(req: NextRequest) {
const signature = req.headers.get('x-cld-signature');
const timestamp = req.headers.get('x-cld-timestamp');
const payload = await req.text();
if (!signature || !timestamp) {
return new NextResponse('Missing signature', { status: 403 });
}
const expected = crypto
.createHash('sha1')
.update(payload + timestamp + apiSecret)
.digest('hex');
if (expected !== signature) {
return new NextResponse('Invalid signature', { status: 403 });
}
const event = JSON.parse(payload) as {
notification_type: string;
public_id: string;
secure_url?: string;
eager?: Array<{ secure_url: string }>;
};
if (event.notification_type === 'eager') {
await markEagerDerivativesReady(event.public_id, event.eager ?? []);
} else if (event.notification_type === 'upload') {
await recordUpload(event.public_id, event.secure_url ?? '');
}
return NextResponse.json({ ok: true });
}
async function markEagerDerivativesReady(publicId: string, derivatives: Array<{ secure_url: string }>) {
// update DB with derivative URLs
}
async function recordUpload(publicId: string, url: string) {
// record upload completion
}
Add a webhook section to CLAUDE.md:
## Webhooks
- Endpoint: src/app/api/webhooks/cloudinary/route.ts
- Configure URL in Cloudinary dashboard under Settings -> Notifications
- Verify signature: SHA1(payload + timestamp + API_SECRET) === x-cld-signature header
- Handle notification_type: upload, eager, moderation, error
- Return 200 for valid events, 403 for invalid signature
- Idempotent: dedupe by public_id + notification_type
Upload preset hardening
The upload preset is the most important security boundary on Cloudinary. A preset configured permissively accepts any file from any IP and applies any transformation. A preset configured tightly only accepts the files you expect.
Recommended preset configuration in the dashboard:
| Setting | Production value |
|---|---|
| Mode | Signed |
| Folder | app-name/uploads/ (fixed) |
| Resource type | Restrict to expected types only |
| Allowed formats | jpg,png,webp,avif (restrict explicitly) |
| Max file size | 10485760 (10 MB) or your real cap |
| Max image width/height | 4000 |
| Tags | app-name,environment-name |
| Auto-tagging | Disable unless needed |
| Categorization | Disable unless needed |
| Moderation | Enable AWS Rekognition or webpurify for user uploads |
| Notification URL | Your webhook endpoint |
The cost-relevant settings are Auto-tagging, Categorization, and Background Removal. Each of these is a paid add-on. Claude does not know they are paid; if asked to "set up Cloudinary with AI features" it may enable all of them, generating an unexpected line item on your bill.
Common Claude Code mistakes with Cloudinary
Six patterns Claude generates incorrectly without CLAUDE.md constraints, with the correct replacement for each.
1. API secret in client env
Claude generates: NEXT_PUBLIC_CLOUDINARY_API_SECRET=... in .env.local.
Correct pattern: CLOUDINARY_API_SECRET=... (no public prefix), server only.
2. Ad-hoc transformations on every render
Claude generates: https://res.cloudinary.com/{cloud}/image/upload/w_${dynamicWidth}/{publicId}.
Correct pattern: named transformations referenced by name (t_avatar, t_card_hero).
3. Unsigned upload preset for user content
Claude generates: unsigned preset for the upload widget because it is simpler.
Correct pattern: signed preset with a signature endpoint that requires auth.
4. Missing f_auto, q_auto
Claude generates: w_800,h_600,c_fill with no format or quality adaptation.
Correct pattern: w_800,h_600,c_fill,q_auto,f_auto for all delivered images.
5. HTTP delivery URLs
Claude generates: http://res.cloudinary.com/... in URL strings.
Correct pattern: https://res.cloudinary.com/... always, and secure: true in SDK config.
6. No eager on first-paint critical images
Claude generates: uploads with no eager transformations, leaving first delivery to on-the-fly.
Correct pattern: eager transformations for the derivatives that appear on the first page after upload.
Add these as before/after pairs in CLAUDE.md.
Permission hooks for Cloudinary scripts
In .claude/settings.local.json:
{
"permissions": {
"allow": [
"Bash(node scripts/list-presets.js*)",
"Bash(node scripts/check-usage.js*)",
"Bash(node scripts/preview-transformation.js*)"
],
"deny": [
"Bash(node scripts/bulk-delete.js*)",
"Bash(node scripts/regenerate-derivatives.js*)",
"Bash(node scripts/purge-cache.js*)"
]
}
}
Bulk delete and cache purges are billable, high-impact operations. The deny list forces Claude to surface those as prompts. For broader hook patterns, Claude Code hooks covers the full configuration model.
Building Cloudinary integrations that scale predictably
The Cloudinary CLAUDE.md in this guide produces media pipelines where every upload is signed, every derivative is generated through a named transformation, the API secret never reaches the client, and webhook signatures are verified before any state change.
The underlying principle is that Cloudinary's flexibility is a double-edged feature. The same URL grammar that lets you serve perfectly sized images for any device also lets you generate thousands of one-off derivatives that cost you money. The CLAUDE.md template channels that flexibility into a small set of named transformations and pre-configured eager outputs, which keeps cache hit rate high and the transformation bill flat as traffic grows.
For the broader file-upload story, Claude Code with AWS S3 covers raw object storage when you need lower-level control, and Claude Code with Cloudflare R2 covers the egress-free alternative. Get Claudify. The bundled Cloudinary template ships with the signed upload pattern, a starter set of named transformations, and the webhook verification helper pre-configured.
More like this
Ready to upgrade your Claude Code setup?
Get Claudify