Magento 2 Product Images Not Showing: The Complete Fix
Grey placeholders, broken thumbnails, or 404s under /media/catalog/product/cache/ have a short, fixable list of causes. This guide works through each one fix-first for Magento 2.4.4 to 2.4.9: regenerating the resized cache, the 660 permission trap after a migration, missing originals, base media URL, CDN cached 404s, and wrong store-view or image roles.
Every product on the category page shows the same grey placeholder, or the PDP hero is a broken-image icon, or the thumbnails throw a 404 under /media/catalog/product/cache/. It usually happens right after a product import or a server move, and it looks worse than it is. The file is almost always fine; something between the original on disk and the browser is not. This guide fixes it fix-first for Magento 2.4.4 to 2.4.9.
Where the broken images show up
The same underlying fault prints as several different symptoms. Match yours to the list, because it points straight at the likely cause.
- Placeholder everywhere. Every product, on every page, shows the default grey placeholder. The storefront is finding no image at all, so it falls back to the placeholder you set under Stores > Configuration > Catalog > Catalog > Product Image Placeholders.
- Broken thumbnails on category and PDP. The browser shows a broken-image icon and the request to a URL under
/media/catalog/product/cache/returns 404. The path is being generated, but the file at that path is not being served. - Fine in admin, broken on the storefront. The product edit page shows the real image, the frontend does not. The original is on disk and readable by PHP, so this is a base-URL, cache, or CDN problem, not a missing file.
- Gone after an import or a server migration. Everything worked yesterday, then you imported products or moved the site and the images vanished. Almost always permissions or missing originals, covered in Fix 2 and Fix 3.
Now work through the causes, ordered by how often each is the real problem.
Diagnose first: three commands that name the fix
Do not start changing config until you know which layer is failing. Three commands, run on the server, tell you almost everything. Pick one broken image, copy its full cache URL from the browser network tab, and start there.
# 1) what status does the web server give for the cached image?
curl -I "https://www.example.com/media/catalog/product/cache/abc123/x/y/xyz.jpg"
# 2) does the ORIGINAL exist on disk, and what mode is it?
ls -l pub/media/catalog/product/x/y/xyz.jpg
# 3) what is nginx actually complaining about?
tail -n 40 /var/log/nginx/error.log
A 404 from curl with the original present on disk points at the missing resized cache (Fix 1) or the base URL and CDN (Fix 4 and Fix 5). A 403 or a "Permission denied" in the nginx log means the file mode is wrong (Fix 2). An ls that says "No such file or directory" means the original never made it (Fix 3). A 200 from curl while the browser still shows a placeholder means the CDN is serving a stale miss (Fix 5).
Fix 1: regenerate the resized image cache
Magento does not serve the file you uploaded. It serves resized variants (small, thumbnail, base, swatch) under pub/media/catalog/product/cache/, each keyed by a hash of the size and settings. If that cache is empty, stale, or was wiped (clearing the folder by hand, or a deploy that did not carry it), every request under cache/ 404s while the originals sit untouched one level up.
Rebuild it with the resize command. It reads every original in pub/media/catalog/product and writes the variant files back into cache/:
bin/magento catalog:images:resize
On a catalog of any size this is slow, and it must not be interrupted by an SSH timeout, so run it detached:
# under screen
screen -S resize
bin/magento catalog:images:resize
# detach with Ctrl-A then D, reattach later with: screen -r resize
# or with nohup, logging to a file
nohup bin/magento catalog:images:resize > var/log/resize.log 2>&1 &
tail -f var/log/resize.log
Magento will also create a missing variant on the first storefront request for it. If your images come back after a full catalog:images:resize but not on their own, the lazy path is blocked, which nearly always circles back to permissions (Fix 2): the PHP worker cannot write into pub/media/catalog/product/cache/.
Fix 2: media file permissions, the 660 trap after a migration
This is the number one cause after a server move or an import. Files copied in by git, scp, rsync, or dropped in by an import script often land mode 660 (owner and group read/write, nothing for "other"). Your PHP process might read them, but the nginx worker runs as a different user and gets nothing, so it answers 403 or 404 for static media. The nginx error log spells it out:
2026/09/08 10:14:07 [error] 812#812: *4 open() "/var/www/html/pub/media/catalog/product/x/y/xyz.jpg"
failed (13: Permission denied), client: 1.2.3.4, server: www.example.com
Confirm the mode, then make the media tree world-readable. Files need r for other, and directories need r and x so the worker can traverse into them:
# confirm the trap
ls -l pub/media/catalog/product/x/y/xyz.jpg
# -rw-rw---- means 'other' cannot read it: that is the bug
# fix files and directories in one pass
chmod -R a+r pub/media/catalog
# make sure directories are traversable (add +x to directories only)
find pub/media/catalog -type d -exec chmod a+rx {} \;
A file can be mode 644 and still 404 if any parent directory lacks the x bit for "other", because the worker cannot descend into it. That is why the find ... -type d ... a+rx line matters as much as the file chmod. If only some images break, check the mode of the two-letter hash directories, not just the file.
The pub/media symlink and docroot
On some stacks pub/media (or pub/media/catalog/product/cache) is a symlink to storage that lives outside the docroot. If the target moved during the migration, or the symlink was copied as a plain broken link, every media request 404s. Check it, and check that your web server docroot points at pub, not the project root:
# is pub/media a live symlink or a real directory?
ls -ld pub/media pub/media/catalog/product/cache
# a dangling symlink shows the target in red and readlink resolves to nothing
readlink -f pub/media
Fix 3: the originals are missing on disk
A database dump and the media folder are two separate things, and it is easy to move one without the other. When you import products, or restore a DB but not pub/media, the rows in catalog_product_entity_media_gallery still point at file paths that no longer exist on disk. Magento asks for a file that is not there and falls back to the placeholder.
Verify it by pulling a sample path from the DB and checking whether that exact file exists:
# grab a few image paths straight from the gallery table
bin/magento setup:db:status >/dev/null 2>&1 # sanity: DB reachable
mysql -e "SELECT value FROM catalog_product_entity_media_gallery LIMIT 5;" magento
# then check one on disk (paths are relative to pub/media/catalog/product)
ls -l pub/media/catalog/product/x/y/xyz.jpg
If the row says /x/y/xyz.jpg and ls reports "No such file or directory", the fix is not in Magento, it is in your copy step: bring pub/media/catalog/product across from the source server. After a fresh product import that referenced images by file, make sure the image files themselves were placed under pub/media/import (or wherever your CSV pointed) before the import ran; the import writes gallery rows regardless of whether the file was found. The full mechanics of image columns in an import are covered in this guide to importing products by CSV.
When you move the media tree, rsync -a preserves the source permissions, which can carry the 660 trap straight across. Either add --chmod=D2775,F664 to the rsync, or run the chmod from Fix 2 immediately after the copy.
Fix 4: wrong base media URL after a domain move
If the admin shows the real image but the storefront shows placeholders or broken links, and the file is present and readable, the storefront is building the wrong URL. After a domain change or a staging-to-production move, web/unsecure/base_media_url and its secure twin can still point at the old host. Every image tag then references a domain that does not serve your media.
Check what is stored. An empty value is the healthy default (Magento derives media URLs from the base URL); a value is only correct if it points at the live host:
bin/magento config:show web/unsecure/base_media_url
bin/magento config:show web/secure/base_media_url
bin/magento config:show web/unsecure/base_url
bin/magento config:show web/secure/base_url
If a media URL points at the old domain, reset it to derive from the base URL, or set it explicitly, then flush:
# let Magento derive it from base_url (usual choice)
bin/magento config:set web/unsecure/base_media_url ""
bin/magento config:set web/secure/base_media_url ""
# or pin it explicitly if you serve media from a subdomain/CDN
# bin/magento config:set web/unsecure/base_media_url "https://cdn.example.com/media/"
# bin/magento config:set web/secure/base_media_url "https://cdn.example.com/media/"
bin/magento cache:flush
If config:set does not seem to take, the value may be pinned in app/etc/env.php under the system array, which overrides the database. Grep for it: grep -R "base_media_url" app/etc/env.php, and remove the stale entry if the migration copied over an old one.
Fix 5: a CDN or Cloudflare caching the 404
Cloudflare and Fastly cache a 404 response and serve it back like a real answer. So you can fix the file on disk, confirm the origin returns 200, and still see a broken image, because the edge is holding the old miss. Prove which layer you are talking to by curling the origin directly and then the public URL:
# the origin (bypass the CDN by hitting the server IP with the right Host header)
curl -I -H "Host: www.example.com" http://ORIGIN_IP/media/catalog/product/cache/abc123/x/y/xyz.jpg
# the public edge URL
curl -I "https://www.example.com/media/catalog/product/cache/abc123/x/y/xyz.jpg"
If the origin says 200 and the public URL says 404, the CDN cached the miss. Purge that path, and to force a fresh fetch immediately, request the image with a version query so it looks like a new URL to the edge:
https://www.example.com/media/catalog/product/cache/abc123/x/y/xyz.jpg?v=2
The version-query trick is a fast confirmation, not a permanent fix. Once you have purged the CDN cache, the clean URL works on its own. If your Fastly or Cloudflare config caches 4xx aggressively, lower the negative-cache TTL for media so a transient miss during a deploy does not stick for hours.
Fix 6: image on the wrong store view, or no role assigned
Sometimes the platform is healthy and the data is wrong. Two data mistakes produce a placeholder even when a file is uploaded:
Assigned to the wrong store view
Image assignments are store-view scoped. If the image was uploaded while the store-view switcher sat on one view, but the category the customer browses renders a different view, the image is not assigned in that scope and the placeholder shows. Open the product, switch the store-view scope at the top left to the affected view, and confirm the image is present there, not only in Default.
No image role assigned
Uploading a file is not enough. Magento shows an image only when it is assigned a role: Base (the PDP main image), Small (category listings), and Thumbnail (cart and mini gallery). If a bulk import created the gallery entry but assigned no roles, the PDP hero and the category thumbnail both fall back to the placeholder while the image sits in the gallery strip. In the product's Images and Videos section, click the image and set the three roles, then save. In an import CSV those are the base_image, small_image, and thumbnail_image columns; leaving them blank while filling additional_images is how you get a full gallery and a placeholder on the listing.
A media deploy checklist that prevents this
Every one of these causes is avoidable with a short, fixed routine whenever you import products or move a server. Run it in this order:
# 1) bring the ORIGINALS across (not just the DB)
rsync -a --chmod=D2775,F664 source:/var/www/html/pub/media/catalog/ pub/media/catalog/
# 2) make media readable by the web server
chmod -R a+r pub/media/catalog
find pub/media/catalog -type d -exec chmod a+rx {} \;
# 3) confirm base URLs point at the live host
bin/magento config:show web/unsecure/base_media_url
# 4) rebuild the resized cache (detached, it is slow)
nohup bin/magento catalog:images:resize > var/log/resize.log 2>&1 &
# 5) flush Magento and purge the CDN
bin/magento cache:flush
After an import, also rebuild indexes so category listings and search reflect the new products and their images; the safe way to do that without locking yourself out is in this guide to reindexing and index management. Bake steps 1 and 2 into your deploy script and the 660 trap never bites you again.
Frequently asked questions
Why do all my product images show the placeholder after an import?
Two usual reasons. Either the image files themselves were not placed on disk (the import wrote catalog_product_entity_media_gallery rows that point at files that are not there), or the files landed mode 660 and the web server cannot read them. Check with ls -l on one path from the gallery table: "No such file" means missing originals, -rw-rw---- means the permission trap.
What does catalog:images:resize actually do?
It reads every original image under pub/media/catalog/product and writes the resized variants (base, small, thumbnail, swatch) into pub/media/catalog/product/cache/. The storefront serves those cache variants, not your uploads, so if the cache is empty every image 404s. Run it under screen or nohup because it is slow on a large catalog and must not be cut off by an SSH timeout.
The image is fine in admin but broken on the storefront. Why?
The original is on disk and readable by PHP (that is why the admin shows it), so the fault is in how the storefront builds or serves the URL. Check web/unsecure/base_media_url for a stale domain after a move, confirm the resized cache exists, and check whether a CDN is serving a cached 404. It is not a missing file.
Which permissions do product media files need?
Files need read for "other" (so the nginx worker can read them), and every directory in the path needs read and execute for "other" (so the worker can traverse into it). chmod -R a+r pub/media/catalog fixes the files; a separate find pub/media/catalog -type d -exec chmod a+rx {} \; ensures directories are traversable. A file can be 644 and still 404 if a parent directory lacks the x bit.
I fixed the file but Cloudflare still shows a 404. What now?
Cloudflare and Fastly cache negative responses, so a 404 served during your broken window sticks at the edge. Curl the origin directly to confirm it now returns 200, then purge the CDN cache for that path. To confirm instantly, append a version query like ?v=2 to the image URL, which the edge treats as a new object and fetches fresh from origin.
Do I need to reindex or reset placeholders after fixing images?
You do not reindex to fix the images themselves, but after an import you should reindex so category and search listings pick up the new products. You do not need to touch the placeholder config unless you changed it; the placeholder only shows because no assigned, resized image was found. Fix the image and the placeholder disappears on its own after a cache:flush.
Only some images are broken, not all. Where do I look?
Selective breakage points at data or per-file permissions rather than a global config fault. Check the specific product's store-view scope and image roles (Base, Small, Thumbnail), and check the mode of the two-letter hash directories for the affected files. A mixed rsync that copied some files with the wrong mode produces exactly this pattern.
Is any of this different between Magento 2.4.4 and 2.4.9?
No. Media storage, the cache/ variant model, catalog:images:resize, the permission requirements, and the base-media-URL config are the same across 2.4.4 to 2.4.9. The commands in this guide run unchanged on every version in that range.
Images still broken? If the placeholders survive every fix here, the cause is usually a filesystem, symlink, or CDN detail specific to your host, and a short look at the live box settles it fast. I run a $499 audit that finds the exact blocker and gets your catalog images serving again. See services or hire me.
Get a Magento developer on it