I found this problem the way most people find it: on an invoice.
A client site — nothing exotic, a WooCommerce store with a blog bolted on — had crept past its disk quota, and the host's answer was the next plan up. Double the money, for storage we only needed because nobody had ever deleted a product photo. I opened wp-content/uploads over FTP and found 60-something gigabytes of images. Roughly a third of it was generated thumbnail sizes for images nobody had linked to since 2021.
That was the annoying part. The part that actually mattered was quieter: all of it was sitting on the same disk, behind the same connection limit, as the store.
One thing up front. We build WordPress plugins, and this article ends with one of ours. We are not affiliated with Automattic or WordPress.org. Everything below is what we hit on our own client work.
Your uploads folder is the most expensive disk you will ever rent
Managed WordPress hosting prices every gigabyte as if it were database. It is not. Most of what you are paying premium NVMe rates for is JPEGs that never change.
An FTP or object-storage box does one job — hand back a file — and it is priced accordingly. Same gigabyte, a fraction of the cost. That alone is usually the whole business case.
But the money is the boring half. On a shared or entry-level VPS host, uploads costs you in ways that do not show up on the invoice:
- Backups. A 60 GB uploads folder means every nightly backup drags 60 GB of files that have not changed since last year. Some hosts silently skip large folders, which is worse — you think you have backups.
- I/O and inodes. Hundreds of thousands of small files compete for the same disk queue your database is using, and inode limits are a real ceiling on cheap cPanel plans.
- Connections. Static files do not run PHP, but they do occupy the same origin, the same connection pool, and the same bandwidth allowance as the requests that do.
Moving media off the origin is not a speed trick. It is taking a job away from a server that was never good at it.
WordPress has no concept of media living somewhere else
Here is the thing people get wrong, and I got wrong for a while too.
You can upload files to wp-content/uploads over FTP. Nothing stops you. What you cannot do is make WordPress care. A file with no attachment post is not in the Media Library, has no alt text, generates no srcset, and cannot be picked in Gutenberg or Elementor. It is just bytes on a disk that WordPress will never mention.
And the reverse is just as stuck. Once a file is registered, its URL is not stored as a URL — it is assembled at runtime:
// wp_get_attachment_url() ends up here
$uploads = wp_get_upload_dir();
$url = $uploads['baseurl'] . '/' . $file; // baseurl derives from your site URL
So even if you move every byte to cdn.example.com, WordPress keeps printing your own domain in front of the filename. The files are gone. The links still point home. Every image on the site 404s.
Offloading media is two separate problems — moving the bytes, and rewriting the URLs — and doing only the first one takes your site down.
The version you can write yourself
You should see the code, because it explains why the code is not enough.
Uploading on the way in is a wp_handle_upload filter and PHP's FTP extension (check ext-ftp is actually compiled in — on plenty of managed hosts it is not):
add_filter( 'wp_handle_upload', function ( $upload ) {
$conn = ftp_connect( 'ftp.example.com', 21, 10 );
if ( ! $conn || ! ftp_login( $conn, FTP_USER, FTP_PASS ) ) {
return $upload; // fail open: keep it local
}
ftp_pasv( $conn, true );
$relative = str_replace(
trailingslashit( wp_get_upload_dir()['basedir'] ),
'',
$upload['file']
); // e.g. 2026/08/photo.jpg
ftp_put( $conn, '/media/' . $relative, $upload['file'], FTP_BINARY );
ftp_close( $conn );
return $upload;
} );
Rewriting on the way out is a second filter, documented under wp_get_attachment_url:
add_filter( 'wp_get_attachment_url', function ( $url ) {
return str_replace(
wp_get_upload_dir()['baseurl'],
'https://cdn.example.com/media',
$url
);
} );
Twenty minutes, and it works. Then:
-
ftp_put()runs inside the visitor's request. The person uploading waits for your FTP server. If it is slow, the admin is slow. If it times out, the upload half-exists. -
One image is not one file. WordPress generates every registered size, and
wp_handle_uploadfires before they exist. You needwp_generate_attachment_metadatatoo, and you need to walk the wholesizesarray. -
wp_get_attachment_urlis not the only place a URL appears. Responsive images come fromwp_calculate_image_srcset. Page builders write absolute URLs straight intopost_content. Miss those and half your images are on the CDN and half are 404s — the worst possible state. - Nothing above touches the 40,000 files already in your library.
Each of those is fixable. Together they are not a snippet in functions.php any more. They are a plugin with a queue, a retry policy, and a log.
An FTP box is not a CDN, and "Google prefers FTP" is not a thing
I want to be straight about this, because the offload-media pitch usually gets it wrong.
Google does not know or care what protocol you used to put a file on a server. There is no ranking signal for FTP. What Google measures is what the visitor experiences, and Largest Contentful Paint — usually your hero image or first product photo — is the metric that offloading actually moves. It moves because the image is no longer queued behind your PHP requests, not because it travelled by FTP.
And the second correction: a single FTP box is storage, not a CDN. One machine, one location. If it sits in Frankfurt, a visitor in São Paulo gets Frankfurt latency, and you may have made their LCP worse than serving from your own origin. A CDN means edge nodes with anycast routing.
The useful shape is: storage that is cheap, with a CDN in front of it. Point cdn.example.com at the storage box, put Cloudflare or Bunny on that hostname, and now the offload is genuinely a performance win instead of a hosting-cost win with a latency tax attached.

What we ended up building
We rebuilt three-quarters of the above on two client projects before turning it into a product. It is called Lumo FTP Media Library, and the setup is deliberately dull:
- Enter the FTP host or IP, username, password, and port, then hit test connection. It fails loudly and specifically — wrong port, passive mode, bad path — instead of silently keeping files local.
- Set the remote root folder and the CDN Base URL (
https://cdn.example.com/media). WordPress keeps building the2026/08/structure underneath. - Turn on URL rewriting. New uploads go straight out and come back through the CDN hostname.
- For the existing library, click Offload library. It queues every file and uploads in small background batches you can pause, resume, or stop.
If you would rather migrate the bulk yourself — zip wp-content/uploads, drop it on the FTP, set the same path in settings — you can, and the plugin verifies the files are reachable before it rewrites anything.
Once you have confirmed images are loading from the CDN hostname, you can switch from Copy to FTP and keep local to Move to FTP and delete local, and finally reclaim the disk. It speaks FTP, SFTP, and S3-compatible endpoints, so BunnyCDN Storage, Wasabi, Backblaze B2 and a plain vsftpd box are all the same to it. It also handles WooCommerce product, variation and gallery images, which is where a lot of generic offload setups quietly break.

Honest caveats — when you should not buy this
If your uploads folder is 2 GB, do nothing. You will not feel this. Compress your images, turn off two or three image sizes you never use, and go build something else.
If you are already all-in on AWS, use the S3-specific tooling. It is built around one provider's API and does that one thing very well. This plugin exists for people whose storage is an FTP or SFTP account, which is what most non-AWS hosting actually hands you.
If you are comfortable in PHP and only have new uploads to worry about — a fresh site, no legacy library — the two filters above genuinely may be enough. Take them. Just know you are also signing up for the srcset case and the day the FTP server is down.
And do not expect offloading to fix a slow site on its own. If your Time to First Byte is two seconds because of an unindexed query or 40 active plugins, moving images changes nothing a visitor will notice. Offloading media fixes a media problem. It is not a performance strategy.
If this is your situation
Where this earns its place is the exact case I opened with: a library that has outgrown the plan it lives on, thousands of existing URLs you cannot afford to break, and no appetite for owning a queue-and-retry system.
The plugin is Lumo FTP Media Library. Before you read another feature list — including this one — watch the demo on the product page. The connection test and the bulk-offload progress screen are the two things worth judging, and thirty seconds of video tells you more about whether this fits your setup than I can.

Top comments (0)