A Cloudinary migration checklist for a small SaaS
Inventory, import, URL mapping and a reversible cutover plan for moving a small SaaS from Cloudinary to Dreep without broken images.
· 7 min read
Moving image hosting looks like a file copy. In practice, the files are the easy part. The work is in the URLs: Cloudinary URLs are stored in your database, baked into emails that have already been sent, cached by search engines and built by helper functions scattered across your codebase. A migration that copies every file but breaks one of those paths still produces broken images.
This is the checklist we would follow for a small SaaS with a few thousand to a few hundred thousand assets and one or two engineers. It uses Dreep's Cloudinary importer for the copy and plain scripts for everything else. Every step is reversible until the last one.
1. Take an inventory before touching anything
You need four numbers and two lists before you can plan: total assets and storage, how many are video, how many are private or authenticated, the transformation strings you actually use, and where Cloudinary URLs are stored versus built at render time.
# Every Cloudinary URL your code or templates build
grep -rnoE "res\.cloudinary\.com/[^\"' )]+" src/ templates/ | sort | uniq -c | sort -rn | head -50
# The transformation strings in use, most common first
grep -rhoE "/(image|video)/upload/[^/]+/" src/ templates/ | sort | uniq -c | sort -rn- Storage total decides the plan: Free has 1 GB, Starter 10 GB and Pro 50 GB, with storage add-ons from $1.99 a month for 10 GB.
- List your named transformations (
t_in URLs). Each becomes a Dreep preset. Free allows 3 presets, Starter 10 and Pro 50. - Look for features Dreep does not have. Overlays and watermarks (
l_layers), automatic format and quality (f_auto,q_auto) and webhook-driven workflows have no direct equivalent. Decide now what replaces them. - Note which assets are authenticated or private in Cloudinary. They belong in Dreep signed folders.
2. Map the transformation syntax
Cloudinary encodes transformations as comma-separated flags in the path. Dreep uses full-name query parameters on the delivery URL. Most everyday flags map one to one:
| Cloudinary | Dreep | Notes |
|---|---|---|
w_400, h_300 | width=400, height=300 | Maximum 4000 |
c_fill | fit=cover | Dreep's default when both sides are set |
c_fit | fit=inside | |
c_pad | fit=contain with bg=ffffff | Padding colour as hex without # |
c_scale with both sides | fit=fill | Ignores aspect ratio |
g_auto, g_face | gravity=attention | Saliency-based, not a face detector |
g_north and other compass points | gravity=north and so on | |
f_webp, f_avif, f_jpg | format=webp, format=avif, format=jpeg | |
f_auto | No equivalent | Choose a format explicitly, or use <picture> |
q_80 | quality=80 | Default is 80 |
q_auto | No equivalent | Pick a fixed quality per use |
dpr_2 | dpr=2 | 1 to 3 |
a_90 | rotate=90 | 90, 180 or 270 only |
r_max | radius=max | |
t_card | p=card | Named transformation becomes a preset |
l_ overlays | Not supported | Composite before upload, or keep on Cloudinary |
For video, so_ and eo_ become trimStart and trimEnd, fps_ becomes fps, and an .m3u8 request becomes format=hls. Dreep produces one HLS rendition per URL, not an adaptive multi-bitrate ladder, so if you relied on streaming profiles, check that a single 720p or 1080p rendition is acceptable.
3. Set up the destination
- Create a Dreep project and an API key. If you want the files in your own S3 or R2 bucket, connect it now, before anything is imported, so every asset lands in the same place. It requires Starter or Pro, and the bucket cannot be changed later.
- Create one Dreep folder per Cloudinary folder under a common root such as
cld/, so filenames stay unique. Use lowercase names that match the Cloudinary folders, because Dreep stores folder paths as slugs and the mapping below compares paths. Create folders for authenticated assets as signed folders before importing, because imported assets inherit their folder's access mode. - Recreate named transformations as presets with the same keys, using lowercase letters, numbers and underscores.
4. Run the import
Dreep's importer connects to Cloudinary with OAuth: open the project's Integrations page, click Connect on the Cloudinary card, enter your Cloud name (shown in the Product Environment section of the Cloudinary console, not your username) and authorise. Then choose Import Assets, browse your Cloudinary library, select files and pick the destination folder.
The transfer runs as a background job, so you can close the tab. The dashboard shows per-file progress and marks each file as done or failed with the error. Re-run failed files rather than the whole folder.
Some things the importer does not do, so you do not assume otherwise:
- It copies originals, not Cloudinary's derived versions. Variants are regenerated by Dreep on first request.
- Dreep assigns its own asset ids. Your Cloudinary public IDs survive only as the stored filename, which is why the folder structure in step 3 matters.
- Files that live outside Cloudinary, for example in an S3 or R2 bucket, have their own importers on the same Integrations page, which run as one-off background jobs in the same way.
- Import video in a small test batch first and check playback. If anything fails, move videos with presigned uploads instead.
5. Build the URL mapping
Once a folder is imported, list it with the SDK and write a mapping from Cloudinary path to Dreep asset. Imported files keep the filename from the Cloudinary URL, so folder/filename is a reliable key as long as each Cloudinary folder went into its own Dreep folder.
import { Dreep } from "dreep";
import fs from "node:fs/promises";
const dreep = new Dreep({ apiKey: process.env.DREEP_API_KEY });
// Imported files keep their Cloudinary filename (public ID + extension).
// Import each Cloudinary folder into a Dreep folder of the same name,
// then build "folder/filename" -> Dreep asset.
const mapping = {};
for await (const asset of dreep.listAllMedia({ folder: "cld", recursive: true })) {
mapping[asset.folder + "/" + asset.filename] = { id: asset.id, url: asset.url };
}
await fs.writeFile("cloudinary-map.json", JSON.stringify(mapping, null, 2));
console.log(Object.keys(mapping).length, "assets mapped");Then a translation function turns any Cloudinary URL into the equivalent Dreep URL, falling back to the original when an asset is not mapped yet. It is deliberately conservative: unknown flags are dropped rather than guessed.
import { Dreep } from "dreep";
const dreep = new Dreep({ apiKey: process.env.DREEP_API_KEY }); // url() itself makes no network call
// Translate the common Cloudinary transformation flags to Dreep params.
const FLAG_MAP = {
w: (v) => ({ width: Number(v) }),
h: (v) => ({ height: Number(v) }),
q: (v) => (v.startsWith("auto") ? {} : { quality: Number(v) }),
f: (v) => ({ format: v === "auto" ? "webp" : v === "jpg" ? "jpeg" : v }), // pick explicitly
c: (v) => ({ fit: { fill: "cover", fit: "inside", pad: "contain", scale: "fill" }[v] }),
g: (v) => ({ gravity: v === "auto" || v === "face" ? "attention" : v.replace("_", "") }),
dpr: (v) => ({ dpr: Number(v) }),
a: (v) => ({ rotate: Number(v) }),
};
const toDreepUrl = (cloudinaryUrl, mapping) => {
// .../image/upload/w_400,c_fill,f_auto/v1712345678/products/shoe.jpg
const match = cloudinaryUrl.match(/\/upload\/(?:([^/]*_[^/]*)\/)?(?:v\d+\/)?(.+)$/);
if (!match) return null;
const [, flags = "", path] = match;
const asset = mapping["cld/" + path];
if (!asset) return null; // not imported yet: keep the Cloudinary URL
const transform = {};
for (const flag of flags.split(",").filter(Boolean)) {
const [key, ...rest] = flag.split("_");
Object.assign(transform, FLAG_MAP[key]?.(rest.join("_")) ?? {});
}
return dreep.url(asset.url, transform);
};6. Cut over in reversible steps
- New uploads first. Point your upload path at Dreep (presigned uploads for browsers) while reads still go to Cloudinary for old assets. Nothing old breaks.
- Render through one helper. Replace scattered URL building with a single
imageUrl()function that callstoDreepUrlbehind a feature flag. Turn it on for internal users, then a percentage of traffic. - Rewrite stored URLs. Add a new column for the Dreep URL or asset id and backfill it from the mapping. Keep the Cloudinary column until the end; it is your rollback.
- Watch Cloudinary traffic fall. Its usage reports show what is still being requested. Remaining hits usually come from sent emails, third-party embeds and search engine image caches.
- Decide on a tail period. Keep the Cloudinary account for a billing cycle or two after traffic is near zero, then export what you need and close it. This is the only irreversible step.
What can go wrong
- Format negotiation: without
f_auto, browsers get exactly the format you ask for. Serve WebP broadly and add AVIF through<picture>if you want it. - Duplicate filenames: two Cloudinary folders imported into one Dreep folder make
folder/filenameambiguous. Keep the folder structure parallel. - Quota during warm-up: after cutover every variant is generated once. A large catalogue can use a month's image transformations quickly; a one-time pack is $1.99 per 10,000 if you need headroom.
- Signed assets: authenticated Cloudinary URLs need a new signing step on your server with Dreep's
signedUrl. The signature format is different, so old signed URLs cannot be translated.
The importer itself is documented in the Cloudinary migration guide, and presets in the presets guide. For a broader comparison, see the Cloudinary alternative page.
Related
Try the examples against your own project
The Free plan includes 1 GB of storage, 100 image and 20 video transformations a month, which is enough to run every snippet in these posts.