Mark Jaquith

WordPress Local Development Without the Uploads Directory

May 6, 2020

When doing local development on a WordPress site, you typically need three things:

  1. Working copy of the codebase.
  2. Recent-ish copy of the database.
  3. The entire contents of wp-content/uploads.

That third one can be problematic. Some WordPress sites have tens or hundreds of thousands of uploads. Let's say a site posts once a day for a decade, and each post has five images and their theme defines 12 image sizes. That's 10 × 365 × 5 × 12 = 219,000 images. If each original is 2MB and each crop is 100KB, you're looking at 55GB of images.

That's going to take a long time to transfer, and take up a lot of space on your development machine, which for many of us is a laptop with relatively limited storage.

But you don't need those files to be local... you just want to be able to develop on the site without a bunch of missing images breaking the layout.

What I do is simple, but extremely useful: redirect requests for missing images to the live site. That way, if you do have a local image (like a new upload in a test post) it will be served from your local machine. But if you are missing an upload, because your database copy is referencing an upload on the live site, it will still work, because it will serve it from the live site.

Here's how to do that!

In all cases, replace all instances of with the scheme and domain of your production site (note: be careful to not add a trailing slash if there isn't already one).


# Put this in your site's Nginx server block.

location ~* \.(jpe?g|gif|png)$ {
  try_files $uri @productionImages =404;

location @productionImages {
  rewrite .$request_uri;


For this one, also replace !^example\.com$ with your production domain, starting with !^, ending with $, and escaping any "dots" as \.. This keeps the rule from running on your production domain.

# Put this in your .htaccess, before the WordPress rules.
RewriteCond %{HTTP_HOST} !^example\.com$
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^(.*)\.(jpe?g|gif|png)$$1.$2 [R=302,NC,L]

Laravel Valet

// Install this file as LocalValetDriver.php in your site root.

class LocalValetDriver extends WordPressValetDriver {
  const PUBLIC_DOMAIN = '';

  public function serves($sitePath, $siteName, $uri) {
    $path = parse_url($uri, PHP_URL_PATH);

    if (
      !file_exists($sitePath . $path) &&
      preg_match('#\.(jpe?g|gif|png)$#i', $path) > 0
    ) {
      $location = self::PUBLIC_DOMAIN . $path;
      header("Location: $location", 302);

    return true;

None of the above?

If you're not using any of the above, or are using a tool where you can't control your server config, Bill Erickson created a WordPress plugin solution: BE Media from Production. Install that, and set the BE_MEDIA_FROM_PRODUCTION_URL to your production site (like and you're off and running. Depending on how image URLs are being output by your site's theme and plugins, this might not be able to replace all of them, because some plugins have this bad habit of storing full production image URLs in options and postmeta, instead of storing attachment IDs. But this will probably replace the vast majority, and doesn't require you to muck around with server config files!