Self-Hosting Ghost with Cloudinary: What the Docs Skip
A working Ghost and Cloudinary integration will happily send a 4K original to someone on a train and never mention it. No errors, no warnings, and the images look fine.
Setting up Ghost with Cloudinary is straightforward. Install the storage adapter, add your credentials to config.production.json, restart, and upload an image. It appears. Everything works.
That is precisely the problem.
A default Cloudinary integration will serve your full-resolution original to every visitor, on every device, forever, without a single warning. No error, no console message, nothing in the admin interface. The images look correct because they are correct. They are simply eight times larger than they need to be.
I found this in my own setup only because I looked for it. This post is the version of the documentation I wanted at the time.
In this post:
- Why the default is quiet
- What your URLs are actually doing
- The setting that overrides your setting
- The configuration that works
- How to verify it, properly
- The AVIF wall
- Three things that caught me out
- My take
Why the Default Is Quiet
Ghost's own image handling generates responsive sizes and a srcset. When you replace the storage layer with an adapter, that behaviour depends on the adapter, and the adapter depends on what you put in its configuration.
Leave the delivery options empty, and Cloudinary does exactly what it is asked: it returns the asset you uploaded. If that asset is a 4K feature image, that is what goes down the wire, to a phone rendering it at 390 points wide.
Nothing about this presents as a bug. The page loads, the image is sharp, and the only symptom is a Largest Contentful Paint figure you were not looking at and a mobile visitor on a slow connection who leaves before the article renders. It is the same shape of failure I keep running into on other platforms. The loud problems get fixed because they are loud. This one is silent, so it survives.
What Your URLs Are Actually Doing
The diagnosis takes about thirty seconds and needs no server access. Open a published post, right-click the feature image, and copy the image address. A Cloudinary URL looks like this:
https://res.cloudinary.com/<cloud>/image/upload/<transformations>/v1234567/filename.webp
Everything between /upload/ and the version segment is your transformation string. Mine, when I first looked, was this:
/image/upload/q_auto/
One parameter. Which means:
No width constraint. The full master goes to every device. On a 4K image that is potentially several megabytes of pure waste, delivered to exactly the people with the least bandwidth to spare.
No format negotiation. Whatever format you uploaded is what everyone receives, regardless of what their browser could handle more efficiently.
And a quality parameter that was not doing what I thought. More on that next, because it is the part I would have missed entirely.
If your transformation segment is empty, the situation is worse: no transformation parameters at all means Cloudinary's account-level quality default doesn't apply either.
The Setting That Overrides Your Setting
This one is worth its own section because the failure is invisible in both places you would look.
Cloudinary's console has an Optimisation page with a Default Image Quality setting. Mine was set to "Automatic - best quality", which is the right choice for flat, hard-edged artwork where aggressive compression shows as banding.
The help text underneath it says, roughly, that the default applies to any image URL with at least one transformation parameter, and that explicitly including a q_ transformation overrides it.
My URLs contained q_auto. Which resolves to q_auto:good, not q_auto:best.
So I chose the conservative setting in the console, and every image was served with the more aggressive one because the URL won. The console showed the setting I wanted. The delivery ignored it. Neither told me.
There are two ways to resolve that. Remove q_ from the URLs and let the account default apply, or write q_auto:best explicitly. I went with explicit, for two reasons. It is self-documenting, so nobody has to remember that the real setting lives in a web console somewhere. And the account default only applies to URLs that carry at least one transformation parameter, so stripping q_ from a URL that has nothing else in it leaves you with no quality processing at all.
The Configuration That Works
The adapter I use is ghost-storage-cloudinary, and the relevant part of config.production.json is the fetch block, which controls delivery. Mine started life as this:
"fetch": {
"quality": "auto",
"cdn_subdomain": "true"
}
It now reads:
"fetch": {
"quality": "auto:best",
"fetch_format": "auto",
"width": 2000,
"crop": "limit",
"secure": true
}
Which produces c_limit,f_auto,q_auto:best,w_2000 in generated URLs. Taking those in turn:
crop: "limit" with a width that scales down but never up. Without limit, a width parameter will happily enlarge a smaller image, which costs bytes and gains nothing.
Pick the width from your actual masters, not from ambition. I set 2000 because my feature images are genuinely larger than that, which gives retina headroom on a typical content column without shipping pixels nobody sees. The number is not the point. Check the real dimensions of what you upload, because a width cap above your master resolution instructs Cloudinary to enlarge the image, which costs bytes and adds nothing. I got this wrong for a while, and I will come back to it.
fetch_format: "auto" lets Cloudinary negotiate the format per request rather than serving whatever you uploaded.
cdn_subdomain removed. It was producing sharded hostnames like res-3.cloudinary.com. Cloudinary support confirmed those aren't deprecated, so this is a preference rather than a fix, but consolidating onto one hostname is better for connection reuse and CDN cache hits.
Validate the file before restarting, because a stray comma stops Ghost from booting:
python3 -m json.tool config.production.json > /dev/null && echo OK
ghost restart
How to Verify It, Properly
Here is the part I would insist on, because everything above is a claim until you measure it.
Upload a fresh image after restarting and copy its URL. Then ask for it twice, pretending to be two different browsers:
URL="https://res.cloudinary.com/<cloud>/image/upload/c_limit,f_auto,q_auto:best,w_2000/<file>"
curl -sI -H "Accept: image/avif,image/webp,*/*" "$URL" \
| grep -i -E "content-type|content-length"
curl -sI -H "Accept: image/webp,*/*" "$URL" \
| grep -i -E "content-type|content-length"
If format negotiation is working, those two return different content types from the same URL. That is the real proof, and you can't establish it by looking at the configuration file.
Two notes. Cloudinary caches per format at the edge, so a first request may be slow or briefly return a fallback. Run it twice. And if you use Safari, you need to enable the Web Inspector under Settings > Advanced before the Network tab is available, which is why I reach for curl regardless of browser.
Then view one delivered image at 100% and check large flat areas or gradients for banding. That is the one thing curl cannot tell you.
The AVIF Wall
My verification returned identical results from both requests. Same content type, same 104,510 bytes. Format negotiation was not happening.
Forcing the issue proved the capability existed:
f_avif → image/avif, 52,582 bytes
f_auto → image/webp, 104,510 bytes
So AVIF worked and halved the file, but f_auto would not select it. I raised a support ticket with those numbers, and the answer was clear: Optimise by default, the setting that enables AVIF selection under f_auto, is available on Enterprise accounts only. On a free plan, you can't turn it on. f_auto selecting WebP is expected behaviour in that configuration.
Their suggested workaround is to put f_avif in the transformation string directly, and I would advise against it. That forces AVIF on every visitor regardless of support, so anything that cannot decode it gets a broken image rather than a graceful fallback. f_auto is being conservative, and conservative is correct.
The honest conclusion is that WebP at a sensible capped width with q_auto:best is a perfectly good outcome. AVIF would have saved another twenty to thirty per cent, which is a refinement. The width cap was always the substantial win, and that works on every plan.
Three Things That Caught Me Out
URLs are built at upload time. The adapter constructs the delivery URL when the file is uploaded, and Ghost stores it in the database. So configuration changes apply only to new uploads. Every already-published post keeps the URL it was given, which is why an account-level setting would have been valuable, and why I asked support whether it applied retroactively before discovering I could not have it anyway. If you want existing posts fixed, you re-upload.
Your booleans may not be booleans. My upload block contained values like "use_filename": "true" and "overwrite": "false", as quoted strings. In JavaScript, the string "false" is truthy, so if an adapter passes these through without coercion, overwrite behaves as true, and a filename collision silently replaces an existing asset. Mine handled it correctly, which I confirmed by uploading a file with a name already in the library and watching Cloudinary append a suffix. It's worth two minutes to check rather than assume, and it's worth writing unquoted booleans in anything you add.
Ghost Pro cannot do any of this. Custom storage adapters require self-hosting. If you are on the managed tier and want your own image pipeline, that is the choice you are actually making.
My Take
The default working is what makes this dangerous. If a misconfigured image pipeline threw an error, everyone would fix it on day one. Instead, it produces correct-looking images at the wrong size, which is indistinguishable from success unless you go and measure. I have written the same sentence about certificate transparency on Android and about App Intents that compile but cannot be resolved, and it keeps being true: the failures that reach users are the quiet ones, because the loud ones get fixed.
Configuration is not verification, and I would treat that as the rule rather than the lesson. I had the right quality setting selected in a web console, yet poor quality was being delivered. The only way to know which was true was to ask the server what it actually sent. Two curl commands settled a question that no amount of reading my own configuration could have answered, and that ratio is roughly typical.
Measure your inputs before tuning your outputs. I set my width cap assuming I was uploading 4K masters. When I finally checked the files rather than the intention, they were a fraction of that, having been upscaled somewhere earlier in my own image pipeline. So the cap I had carefully chosen was instructing Cloudinary to enlarge every image on the way out. I fixed the generation step, and the cap is now correct, but the sequence is the embarrassing part: I tuned the output of a pipeline whose input I had never measured. Every parameter above depends on knowing the real dimensions of what you upload.
And the proportionate conclusion. After all of that, the change that mattered was one width parameter. Format negotiation turned out to be plan-gated, quality was a refinement, and the hostname change was tidiness. If you do exactly one thing after reading this, cap the delivery width to your actual master resolution and ignore the rest until you have.
FAQ
Does Ghost with Cloudinary work out of the box?
Yes, and that is the issue. Images upload and display correctly, but they may be served at full resolution to every device.
How do I check what my site is delivering?
Copy a published image URL and look at the segment between /upload/ and the version number. If it is empty or contains only a quality parameter, no width constraint or format negotiation is being applied.
Why is my Cloudinary console quality setting being ignored?
An explicit q_ transformation in the URL overrides the account default. q_auto resolves to q_auto:good, so a console setting of best quality has no effect on those URLs.
Will configuration changes fix my existing posts?
No. The adapter builds each URL at upload time, and Ghost stores it, so changes apply only to new uploads.
Why does f_auto serve WebP instead of AVIF?
AVIF selection under f_auto requires Cloudinary's Optimise by default setting, which is available only on Enterprise accounts. Forcing f_avif is not a safe substitute, since it removes the fallback for browsers that cannot decode it.
Can I use a custom storage adapter on Ghost Pro?
No. Custom storage adapters require a self-hosted installation.