Diagnostic Modules, links, and data

Broken Medusa product image links

A product that had a perfectly good thumbnail last week now shows a broken image icon in the storefront and the Admin. Nothing about the product changed. What changed is the file that URL points to, or the host that used to serve it. Here is why Medusa never notices a stored image URL has gone stale, and a small script that scans every product, tells you exactly which images are dead, and only ever clears or replaces the ones you confirm.

Python and Node.js Medusa Admin API Safe by default (dry run)
The short answer

Medusa v2 stores thumbnail and each images[].url as an opaque string on the product record. The framework never re-checks at read time whether that URL still resolves, so it only stays valid as long as the file keeps existing at that exact host and path under whichever File Module Provider generated it. A redeploy with the Local File Module Provider, a container restart on ephemeral storage, a reverse proxy or domain change, or a migration to a new file provider or bucket, all leave old URLs pointing at nothing. Run a small Python or Node.js script that lists every product image through the Admin API, checks each URL with a HEAD request, flags anything unreachable or pointing at a host outside your current backend or CDN, and reports it. Clearing or replacing a broken image is only ever done through a guarded, dry run first workflow, never by guessing a new URL. Full code, tests, and the exact classification rule are below.

The problem in plain words

When you upload a product image, Medusa's File Module Provider stores the file somewhere and hands back a URL. Medusa saves that URL string on the product as thumbnail or as an entry in images. From that point on, Medusa treats the string as done. It never checks whether the file is still there when the product is fetched, rendered in the Admin, or shown on the storefront. It just prints the string into an <img> tag and trusts it.

That trust breaks in two very common, very well documented ways. First, the Local File Module Provider is disk backed, meant for development, and it bakes the host that was active at upload time directly into the URL, so a lot of stores end up with URLs frozen as http://localhost:9000/uploads/.... The moment you redeploy, restart a container with ephemeral storage, or change domains behind a reverse proxy, every one of those URLs points at a host that no longer serves the file, and every image 404s at once. Second, when a store migrates its file provider, for example from local storage to S3, R2, or MinIO, or between buckets or regions, the old product rows keep pointing at the previous provider's URL while the object no longer exists at that location.

Upload saves URL thumbnail / images[].url Redeploy or migration host changes, file moves stored URL never re-checked URL now dead 404, wrong host, or gone Broken image icon
The product row never changes. Only the thing the URL points at changes, and Medusa has no mechanism that would notice.

Why it happens

A few concrete situations account for almost every broken image report:

This shows up constantly in the Medusa community as products whose thumbnail is frozen on localhost:9000 in production, or storefronts where no images render at all right after a fresh setup. See the citations at the end for the exact GitHub issues.

The key insight

An image URL is just a string that Medusa trusts forever. It is never safe to auto-fix by rewriting that string, because the original file may genuinely be gone, and guessing a replacement key risks pointing the product at the wrong asset entirely. The right pattern is detect first, always: paginate every product, check every URL with a real HTTP request, and only ever clear a dead entry or swap in a URL that came back from a real upload through the Admin API. Never hand-construct a URL and write it to a product.

The fix, as a flow

The script pages through every product with its images, collects the full set of candidate URLs, and checks each one against your currently configured backend or CDN hosts before it even makes a request. It then issues a HEAD request to see if the file is actually reachable. A pure function turns that into a clear verdict, and only confirmed-broken entries get a guarded repair, gated by DRY_RUN, that either clears the field or swaps in a URL a human supplied by uploading a real replacement file first.

List product images thumbnail + images[].url HEAD each URL status, error, host classifyImageHealth pure, no I/O ok or broken? broken ok, leave alone DRY_RUN guard clear or replace
Detection is always safe to run. Repair only ever touches a confirmed broken entry, and only for real once DRY_RUN is turned off.

Build it step by step

1

Get an admin session and know your current hosts

Exchange your admin email and password for a JWT at POST /auth/user/emailpass. You will also need the list of hosts your backend and CDN currently serve files from, so the script can flag a URL as suspect even before it makes a request. A URL whose host is not in that list is a strong signal of a stale local provider URL or a pre-migration bucket.

setup (shell)
pip install requests

export MEDUSA_BACKEND_URL="http://localhost:9000"
export MEDUSA_ADMIN_EMAIL="admin@example.com"
export MEDUSA_ADMIN_PASSWORD="supersecret"
export CONFIGURED_IMAGE_HOSTS="localhost:9000,cdn.example.com,my-bucket.s3.amazonaws.com"
export DRY_RUN="true"   # start safe, change to false to let the repair actually write
setup (shell)
npm install @medusajs/js-sdk

export MEDUSA_BACKEND_URL="http://localhost:9000"
export MEDUSA_ADMIN_EMAIL="admin@example.com"
export MEDUSA_ADMIN_PASSWORD="supersecret"
export CONFIGURED_IMAGE_HOSTS="localhost:9000,cdn.example.com,my-bucket.s3.amazonaws.com"
export DRY_RUN="true"   // start safe, change to false to let the repair actually write
2

Decide with one pure function, no I/O

Keep the classification rule in its own function that takes a plain check object, the URL, the status code or error, and the list of configured hosts, and returns a state. It never makes a network call, which makes it trivial to test with canned fixtures. A malformed URL fails first, then a network error or a 4xx or 5xx status marks it unreachable, then a host outside your configured list is flagged even if it happened to still respond, and anything left is ok.

decide.py
from urllib.parse import urlparse


def classify_image_health(check):
    """Pure decision function. No I/O.

    check: {"url": str, "status": int | None, "error": str | None,
            "configured_hosts": list[str]}
    Returns {"state": "ok" | "unreachable" | "foreign_host" | "malformed", "reason": str}.
    """
    url = check.get("url") or ""
    parsed = urlparse(url)
    if not parsed.scheme or not parsed.netloc:
        return {"state": "malformed", "reason": "not a valid absolute URL"}

    status = check.get("status")
    error = check.get("error")
    if error or status is None or status >= 400:
        return {"state": "unreachable", "reason": f"status {status if status is not None else 'network_error'}"}

    host = parsed.hostname or ""
    configured = [h.lower() for h in (check.get("configured_hosts") or [])]
    if host.lower() not in configured:
        return {
            "state": "foreign_host",
            "reason": "points at a non-current storage host (likely stale provider/migration)",
        }

    return {"state": "ok", "reason": "resolves on a currently configured host"}
decide.js
export function classifyImageHealth(check) {
  const url = check.url || "";
  let parsed;
  try {
    parsed = new URL(url);
  } catch {
    return { state: "malformed", reason: "not a valid absolute URL" };
  }

  const { status, error } = check;
  if (error || status === null || status === undefined || status >= 400) {
    return { state: "unreachable", reason: `status ${status ?? "network_error"}` };
  }

  const host = parsed.hostname.toLowerCase();
  const configured = (check.configuredHosts || []).map((h) => h.toLowerCase());
  if (!configured.includes(host)) {
    return {
      state: "foreign_host",
      reason: "points at a non-current storage host (likely stale provider/migration)",
    };
  }

  return { state: "ok", reason: "resolves on a currently configured host" };
}
3

Paginate every product and collect its image URLs

Ask the Admin API for id, title, thumbnail, *images and page through with limit and offset. Build the full candidate URL set from product.thumbnail plus each images[i].url, since image rows are not separately fetchable by an admin route, they only exist attached to the product.

list_products.py
def list_products_with_images(token):
    limit = 100
    offset = 0
    while True:
        r = requests.get(
            f"{BACKEND_URL}/admin/products",
            headers={"Authorization": f"Bearer {token}"},
            params={"limit": limit, "offset": offset, "fields": "id,title,thumbnail,*images"},
            timeout=30,
        )
        r.raise_for_status()
        body = r.json()
        for product in body["products"]:
            yield product
        offset += limit
        if offset >= body["count"]:
            return


def image_entries(product):
    entries = []
    if product.get("thumbnail"):
        entries.append({"field": "thumbnail", "url": product["thumbnail"]})
    for i, image in enumerate(product.get("images") or []):
        if image.get("url"):
            entries.append({"field": f"images[{i}].url", "url": image["url"]})
    return entries
list-products.js
async function* listProductsWithImages(sdk) {
  const limit = 100;
  let offset = 0;
  while (true) {
    const { products, count } = await sdk.admin.product.list({
      limit,
      offset,
      fields: "id,title,thumbnail,*images",
    });
    for (const product of products) yield product;
    offset += limit;
    if (offset >= count) return;
  }
}

function imageEntries(product) {
  const entries = [];
  if (product.thumbnail) entries.push({ field: "thumbnail", url: product.thumbnail });
  (product.images || []).forEach((image, i) => {
    if (image.url) entries.push({ field: `images[${i}].url`, url: image.url });
  });
  return entries;
}
4

Check every unique URL and build the report

Issue a HEAD request per unique URL with a short timeout, falling back to a small ranged GET if the storage provider or CDN disallows HEAD. Treat network errors, 403, 404, and 5xx as broken. Feed the result into classify_image_health and collect {product_id, title, field, url, status, error, state, reason} for every entry that is not ok.

check_url.py
def check_url(url, timeout=10):
    try:
        r = requests.head(url, timeout=timeout, allow_redirects=True)
        if r.status_code == 405:
            r = requests.get(url, timeout=timeout, headers={"Range": "bytes=0-0"}, stream=True)
        return {"status": r.status_code, "error": None}
    except requests.RequestException as exc:
        return {"status": None, "error": str(exc)}
check-url.js
async function checkUrl(url, timeoutMs = 10000) {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), timeoutMs);
  try {
    let res = await fetch(url, { method: "HEAD", signal: controller.signal });
    if (res.status === 405) {
      res = await fetch(url, { method: "GET", headers: { Range: "bytes=0-0" }, signal: controller.signal });
    }
    return { status: res.status, error: null };
  } catch (err) {
    return { status: null, error: err.message };
  } finally {
    clearTimeout(timer);
  }
}
5

Repair only confirmed-broken entries, behind a dry run guard

For each broken entry, log the diff first. Only when DRY_RUN=false do you send the real body. Clear a dead thumbnail with {thumbnail: null}, or drop a dead image by filtering it out of the images array, so the storefront stops rendering a broken <img>. If an operator supplies a real replacement file, upload it first through POST /admin/uploads and only ever write back the URL that endpoint returns, never a hand-built string.

repair.py
def clear_broken_field(token, product, field, dry_run):
    if field == "thumbnail":
        body = {"thumbnail": None}
    else:
        broken_url = product["thumbnail"] if field == "thumbnail" else None
        remaining = [
            img for img in (product.get("images") or [])
            if img["url"] != _url_for_field(product, field)
        ]
        body = {"images": remaining}

    log.info("%s product %s field %s -> %s",
             "Would clear" if dry_run else "Clearing", product["id"], field, body)
    if dry_run:
        return None
    r = requests.post(
        f"{BACKEND_URL}/admin/products/{product['id']}",
        headers={"Authorization": f"Bearer {token}"},
        json=body,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["product"]
repair.js
async function clearBrokenField(sdk, product, field, brokenUrl, dryRun) {
  const body = field === "thumbnail"
    ? { thumbnail: null }
    : { images: (product.images || []).filter((img) => img.url !== brokenUrl) };

  console.log(`${dryRun ? "Would clear" : "Clearing"} product ${product.id} field ${field} ->`, body);
  if (dryRun) return null;
  return sdk.admin.product.update(product.id, body);
}
Never guess a replacement URL

Do not hand-construct a URL string and write it to a product, even if it looks like it should be right. The only URL the repair step is ever allowed to write is one returned by POST /admin/uploads after a real file was uploaded through the currently configured File Module Provider. Everything else is either clear the field, or leave it for a human.

The full code

Here is the complete script in one file for each language. It authenticates against the Admin API, paginates every product, checks every unique image URL with a HEAD request, classifies each one with the pure function, logs a full report, and only clears confirmed-broken entries when DRY_RUN is turned off.

View this code on GitHub Full runnable folder with tests in the medusa-fixes repo.

find_broken_images.py
"""Find and safely repair broken Medusa v2 product image links.

Medusa stores thumbnail and each images[].url as a plain string and never
re-validates it at read time. A redeploy with the Local File Module Provider,
an ephemeral container restart, a domain change, or a file provider migration
all leave old URLs pointing at nothing. This script paginates every product,
checks every unique image URL with a HEAD request, classifies each one with a
pure function, and only ever clears a confirmed-broken field. It never
guesses a replacement URL. Run on a schedule or by hand. Safe to run again
and again.
"""
import os
import logging
import requests
from urllib.parse import urlparse

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("find_broken_images")

BACKEND_URL = os.environ["MEDUSA_BACKEND_URL"]
ADMIN_EMAIL = os.environ["MEDUSA_ADMIN_EMAIL"]
ADMIN_PASSWORD = os.environ["MEDUSA_ADMIN_PASSWORD"]
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
CONFIGURED_IMAGE_HOSTS = [
    h.strip() for h in os.environ.get("CONFIGURED_IMAGE_HOSTS", "localhost:9000").split(",") if h.strip()
]


def classify_image_health(check):
    """Pure decision function. No I/O.

    check: {"url": str, "status": int | None, "error": str | None,
            "configured_hosts": list[str]}
    Returns {"state": "ok" | "unreachable" | "foreign_host" | "malformed", "reason": str}.
    """
    url = check.get("url") or ""
    parsed = urlparse(url)
    if not parsed.scheme or not parsed.netloc:
        return {"state": "malformed", "reason": "not a valid absolute URL"}

    status = check.get("status")
    error = check.get("error")
    if error or status is None or status >= 400:
        return {"state": "unreachable", "reason": f"status {status if status is not None else 'network_error'}"}

    host = parsed.hostname or ""
    configured = [h.lower() for h in (check.get("configured_hosts") or [])]
    if host.lower() not in configured:
        return {
            "state": "foreign_host",
            "reason": "points at a non-current storage host (likely stale provider/migration)",
        }

    return {"state": "ok", "reason": "resolves on a currently configured host"}


def get_admin_token():
    r = requests.post(
        f"{BACKEND_URL}/auth/user/emailpass",
        json={"email": ADMIN_EMAIL, "password": ADMIN_PASSWORD},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["token"]


def list_products_with_images(token):
    limit = 100
    offset = 0
    while True:
        r = requests.get(
            f"{BACKEND_URL}/admin/products",
            headers={"Authorization": f"Bearer {token}"},
            params={"limit": limit, "offset": offset, "fields": "id,title,thumbnail,*images"},
            timeout=30,
        )
        r.raise_for_status()
        body = r.json()
        for product in body["products"]:
            yield product
        offset += limit
        if offset >= body["count"]:
            return


def image_entries(product):
    entries = []
    if product.get("thumbnail"):
        entries.append({"field": "thumbnail", "url": product["thumbnail"]})
    for i, image in enumerate(product.get("images") or []):
        if image.get("url"):
            entries.append({"field": f"images[{i}].url", "url": image["url"]})
    return entries


def check_url(url, timeout=10):
    try:
        r = requests.head(url, timeout=timeout, allow_redirects=True)
        if r.status_code == 405:
            r = requests.get(url, timeout=timeout, headers={"Range": "bytes=0-0"}, stream=True)
        return {"status": r.status_code, "error": None}
    except requests.RequestException as exc:
        return {"status": None, "error": str(exc)}


def clear_broken_field(token, product, field, url, dry_run):
    if field == "thumbnail":
        body = {"thumbnail": None}
    else:
        body = {"images": [img for img in (product.get("images") or []) if img.get("url") != url]}

    log.info("%s product %s field %s", "Would clear" if dry_run else "Clearing", product["id"], field)
    if dry_run:
        return None
    r = requests.post(
        f"{BACKEND_URL}/admin/products/{product['id']}",
        headers={"Authorization": f"Bearer {token}"},
        json=body,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["product"]


def run():
    token = get_admin_token()
    checked_urls = {}
    broken = 0

    for product in list_products_with_images(token):
        for entry in image_entries(product):
            url = entry["url"]
            if url not in checked_urls:
                checked_urls[url] = check_url(url)
            result = checked_urls[url]

            verdict = classify_image_health({
                "url": url,
                "status": result["status"],
                "error": result["error"],
                "configured_hosts": CONFIGURED_IMAGE_HOSTS,
            })

            if verdict["state"] == "ok":
                continue

            log.warning(
                "Product %s (%s) field %s state=%s reason=%s url=%s",
                product["id"], product["title"], entry["field"], verdict["state"], verdict["reason"], url,
            )
            clear_broken_field(token, product, entry["field"], url, DRY_RUN)
            broken += 1

    log.info("Done. %d broken image entr%s %s.", broken, "y" if broken == 1 else "ies",
              "to clear" if DRY_RUN else "cleared")


if __name__ == "__main__":
    run()
find-broken-images.js
/**
 * Find and safely repair broken Medusa v2 product image links.
 *
 * Medusa stores thumbnail and each images[].url as a plain string and never
 * re-validates it at read time. A redeploy with the Local File Module
 * Provider, an ephemeral container restart, a domain change, or a file
 * provider migration all leave old URLs pointing at nothing. This script
 * paginates every product, checks every unique image URL with a HEAD
 * request, classifies each one with a pure function, and only ever clears a
 * confirmed-broken field. It never guesses a replacement URL. Run on a
 * schedule or by hand. Safe to run again and again.
 *
 * Guide: https://www.allanninal.dev/medusa/broken-product-image-links/
 */
import { pathToFileURL } from "node:url";
import Medusa from "@medusajs/js-sdk";

const BACKEND_URL = process.env.MEDUSA_BACKEND_URL || "http://localhost:9000";
const ADMIN_EMAIL = process.env.MEDUSA_ADMIN_EMAIL || "admin@example.com";
const ADMIN_PASSWORD = process.env.MEDUSA_ADMIN_PASSWORD || "supersecret";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const CONFIGURED_IMAGE_HOSTS = (process.env.CONFIGURED_IMAGE_HOSTS || "localhost:9000")
  .split(",")
  .map((h) => h.trim())
  .filter(Boolean);

/**
 * Pure decision function. No I/O.
 *
 * @param {{url: string, status: number|null, error: string|null, configuredHosts: string[]}} check
 * @returns {{state: "ok"|"unreachable"|"foreign_host"|"malformed", reason: string}}
 */
export function classifyImageHealth(check) {
  const url = check.url || "";
  let parsed;
  try {
    parsed = new URL(url);
  } catch {
    return { state: "malformed", reason: "not a valid absolute URL" };
  }

  const { status, error } = check;
  if (error || status === null || status === undefined || status >= 400) {
    return { state: "unreachable", reason: `status ${status ?? "network_error"}` };
  }

  const host = parsed.hostname.toLowerCase();
  const configured = (check.configuredHosts || []).map((h) => h.toLowerCase());
  if (!configured.includes(host)) {
    return {
      state: "foreign_host",
      reason: "points at a non-current storage host (likely stale provider/migration)",
    };
  }

  return { state: "ok", reason: "resolves on a currently configured host" };
}

async function* listProductsWithImages(sdk) {
  const limit = 100;
  let offset = 0;
  while (true) {
    const { products, count } = await sdk.admin.product.list({
      limit,
      offset,
      fields: "id,title,thumbnail,*images",
    });
    for (const product of products) yield product;
    offset += limit;
    if (offset >= count) return;
  }
}

function imageEntries(product) {
  const entries = [];
  if (product.thumbnail) entries.push({ field: "thumbnail", url: product.thumbnail });
  (product.images || []).forEach((image, i) => {
    if (image.url) entries.push({ field: `images[${i}].url`, url: image.url });
  });
  return entries;
}

async function checkUrl(url, timeoutMs = 10000) {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), timeoutMs);
  try {
    let res = await fetch(url, { method: "HEAD", signal: controller.signal });
    if (res.status === 405) {
      res = await fetch(url, { method: "GET", headers: { Range: "bytes=0-0" }, signal: controller.signal });
    }
    return { status: res.status, error: null };
  } catch (err) {
    return { status: null, error: err.message };
  } finally {
    clearTimeout(timer);
  }
}

async function clearBrokenField(sdk, product, field, url, dryRun) {
  const body = field === "thumbnail"
    ? { thumbnail: null }
    : { images: (product.images || []).filter((img) => img.url !== url) };

  console.warn(`${dryRun ? "Would clear" : "Clearing"} product ${product.id} field ${field}`);
  if (dryRun) return null;
  return sdk.admin.product.update(product.id, body);
}

export async function run() {
  const sdk = new Medusa({ baseUrl: BACKEND_URL, auth: { type: "jwt" } });
  await sdk.auth.login("user", "emailpass", { email: ADMIN_EMAIL, password: ADMIN_PASSWORD });

  const checkedUrls = new Map();
  let broken = 0;

  for await (const product of listProductsWithImages(sdk)) {
    for (const entry of imageEntries(product)) {
      const { url } = entry;
      if (!checkedUrls.has(url)) {
        checkedUrls.set(url, await checkUrl(url));
      }
      const result = checkedUrls.get(url);

      const verdict = classifyImageHealth({
        url,
        status: result.status,
        error: result.error,
        configuredHosts: CONFIGURED_IMAGE_HOSTS,
      });

      if (verdict.state === "ok") continue;

      console.warn(
        `Product ${product.id} (${product.title}) field ${entry.field} state=${verdict.state} ` +
        `reason=${verdict.reason} url=${url}`
      );
      await clearBrokenField(sdk, product, entry.field, url, DRY_RUN);
      broken++;
    }
  }

  console.log(`Done. ${broken} broken image entr${broken === 1 ? "y" : "ies"} ${DRY_RUN ? "to clear" : "cleared"}.`);
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  run().catch((err) => { console.error(err); process.exit(1); });
}

Add a test

The classification rule is the part most worth testing, because it decides whether a product image gets left alone or cleared. Since classify_image_health is pure, the test needs no network and no Medusa backend. It just feeds in canned check objects and asserts the state.

test_broken_image_health.py
from find_broken_images import classify_image_health

HOSTS = ["cdn.example.com", "my-bucket.s3.amazonaws.com"]


def test_ok_when_status_200_on_configured_host():
    check = {"url": "https://cdn.example.com/shirt.jpg", "status": 200, "error": None, "configured_hosts": HOSTS}
    assert classify_image_health(check)["state"] == "ok"


def test_unreachable_on_404_same_host():
    check = {"url": "https://cdn.example.com/gone.jpg", "status": 404, "error": None, "configured_hosts": HOSTS}
    assert classify_image_health(check)["state"] == "unreachable"


def test_unreachable_on_network_error():
    check = {"url": "https://cdn.example.com/timeout.jpg", "status": None, "error": "timed out", "configured_hosts": HOSTS}
    assert classify_image_health(check)["state"] == "unreachable"


def test_unreachable_on_5xx():
    check = {"url": "https://cdn.example.com/oops.jpg", "status": 503, "error": None, "configured_hosts": HOSTS}
    assert classify_image_health(check)["state"] == "unreachable"


def test_foreign_host_even_when_200():
    check = {"url": "http://localhost:9000/uploads/old.jpg", "status": 200, "error": None, "configured_hosts": HOSTS}
    assert classify_image_health(check)["state"] == "foreign_host"


def test_malformed_url_string():
    check = {"url": "not-a-url", "status": None, "error": None, "configured_hosts": HOSTS}
    assert classify_image_health(check)["state"] == "malformed"


def test_malformed_wins_over_status():
    check = {"url": "", "status": 200, "error": None, "configured_hosts": HOSTS}
    assert classify_image_health(check)["state"] == "malformed"
broken-image-health.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { classifyImageHealth } from "./find-broken-images.js";

const HOSTS = ["cdn.example.com", "my-bucket.s3.amazonaws.com"];

test("ok when status 200 on configured host", () => {
  const check = { url: "https://cdn.example.com/shirt.jpg", status: 200, error: null, configuredHosts: HOSTS };
  assert.equal(classifyImageHealth(check).state, "ok");
});

test("unreachable on 404 same host", () => {
  const check = { url: "https://cdn.example.com/gone.jpg", status: 404, error: null, configuredHosts: HOSTS };
  assert.equal(classifyImageHealth(check).state, "unreachable");
});

test("unreachable on network error", () => {
  const check = { url: "https://cdn.example.com/timeout.jpg", status: null, error: "timed out", configuredHosts: HOSTS };
  assert.equal(classifyImageHealth(check).state, "unreachable");
});

test("unreachable on 5xx", () => {
  const check = { url: "https://cdn.example.com/oops.jpg", status: 503, error: null, configuredHosts: HOSTS };
  assert.equal(classifyImageHealth(check).state, "unreachable");
});

test("foreign host even when 200", () => {
  const check = { url: "http://localhost:9000/uploads/old.jpg", status: 200, error: null, configuredHosts: HOSTS };
  assert.equal(classifyImageHealth(check).state, "foreign_host");
});

test("malformed url string", () => {
  const check = { url: "not-a-url", status: null, error: null, configuredHosts: HOSTS };
  assert.equal(classifyImageHealth(check).state, "malformed");
});

test("malformed wins over status", () => {
  const check = { url: "", status: 200, error: null, configuredHosts: HOSTS };
  assert.equal(classifyImageHealth(check).state, "malformed");
});

Case studies

Redeploy on localhost

The store that lost every image on launch day

A team built and tested their catalog locally with the default Local File Module Provider, uploaded a few hundred product photos, and everything looked right in dev. On launch day they redeployed to a new production host. Every single thumbnail and gallery image went blank, because every URL was frozen as http://localhost:9000/uploads/... and that host never existed in production.

Running the scan against the real production hosts flagged every one of those URLs as foreign_host in seconds, well before a single request even hit the network for most of them. The team re-uploaded the real image files through /admin/uploads under the production file provider and patched each product with the fresh URL, never guessing a single string.

S3 migration

The bucket that moved regions

A growing store migrated its file provider from a single region S3 bucket to a new bucket in a different region for latency reasons. The migration script copied the newest products' files but missed a batch of older, rarely edited products, whose rows still pointed at the old bucket's URL pattern.

The scan's HEAD checks came back 403 for those old URLs, since the bucket policy no longer allowed public reads on the retired bucket. The report named the exact product ids and fields, and an operator uploaded the missing files through the Admin API into the new bucket and patched each one with the returned URL.

What good looks like

After this scan runs, you have a complete, evidence-backed list of every broken product image, tagged with exactly why it is broken and which product and field it lives on. Nothing was rewritten based on a guess. Confirmed-dead entries were cleared so the storefront stops showing a broken icon, and anything a human wants to restore goes through a real upload, so the URL on the product always matches a file that actually exists.

FAQ

Why do my Medusa product images suddenly all show as broken?

Medusa stores thumbnail and each images[].url as a plain string, and it never checks at read time whether that URL still resolves. If you used the Local File Module Provider, the URL bakes in the host that was active at upload time, often http://localhost:9000. A redeploy, a container restart with ephemeral disk storage, or a domain change leaves every old URL pointing at a host that no longer serves that file, so every image 404s at once.

Is it safe to bulk fix broken image URLs by rewriting them?

No. There is no reliable way to guess the correct replacement URL for a file that used to live under a different provider or host, because the object itself may genuinely be gone. The safe pattern is to detect and report every broken URL first, then only clear the field or upload a real replacement file through the Admin API, never hand-construct a URL string and write it back.

How do I tell a moved file provider apart from a truly deleted image?

Check the URL host against your currently configured backend and CDN domains before you even issue a request. A URL on a foreign host, such as an old localhost address or a bucket you migrated away from, is a strong signal the provider changed. Then issue a HEAD request against the currently configured provider's expected path for that same file. If it resolves there, the object moved. If it fails everywhere, the object is most likely gone for good.

Related field notes

Citations

On the problem:

  1. Medusa GitHub: Product images not working, always point to localhost:9000, issue #12651. github.com/medusajs/medusa/issues/12651
  2. Medusa GitHub: Thumbnail URL set to localhost, issue #12146. github.com/medusajs/medusa/issues/12146
  3. Medusa GitHub: Medusa 2.0 Admin/Storefront, no images show up at all when creating products, issue #8000. github.com/medusajs/medusa/issues/8000

On the solution:

  1. Medusa Documentation: Local File Module Provider. docs.medusajs.com/resources/infrastructure-modules/file/local
  2. Medusa Documentation: How to Create a File Module Provider. docs.medusajs.com/resources/references/file-provider-module
  3. Medusa Documentation: Admin API Reference. docs.medusajs.com/api/admin

Stuck on a tricky one?

If you have a problem in Medusa storefront access, pricing, inventory, orders, promotions, or workflows that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.

Contact me on LinkedIn

Did this untangle your broken images?

If this saved you a night of guessing at file URLs, you can buy me a coffee. It is the best way to keep these field notes free and growing.

Buy me a coffee on Ko-fi

Back to all Medusa.js field notes