Here’s how to move an existing WordPress media library to Amazon S3 with WP Offload Media Lite and the AWS CLI. Lite handles new uploads. We’ll copy the old files separately and update their URLs.
We used this approach on a gallery site with 75 GB of uploads and more than 13000 attachments. Keeping old images working and private files out of the bucket took more care than the file transfer.
You’ll need SSH, WP-CLI and some PHP knowledge. This guide uses public S3 URLs and keeps local copies while you test. The paid bulk offload tool is simpler if you want the plugin to manage the existing library too.
What WP Offload Media Lite can and cannot do
- New uploads: Lite copies them to S3 and serves their cloud URLs.
- Existing uploads: the bulk offload tool is paid. A CLI copy does not register files with the plugin.
- Manual migration: you manage old files and later changes yourself.
- Costs: AWS storage, requests, transfer and any CDN cost extra.
Removing local copies can free server space and shrink backups. Faster pages still depend on image sizes, caching and delivery, not just S3.
Plugin settings reference: https://deliciousbrains.com/wp-offload-media/doc/settings/
Before you start
Back up the database and uploads, then test on staging. Check wp-content/uploads for form entries, protected downloads, logs and exports. Private files can sit inside dated folders too.
This guide assumes one site with public media in YYYY/MM/ folders. Other layouts need a different mapping.
Keep the S3 paths the same as the local files, for example wp-content/uploads/2024/05/photo.jpg. Copy thumbnails, scaled originals and WebP or AVIF versions too.
Turn off Add Object Version to Bucket Path so paths match. S3 bucket versioning is separate and can stay on.
This guide uses the S3 bucket address. A custom media domain makes future provider changes easier.
Nginx may serve local images before Apache sees the request, so an .htaccess redirect may never run. Check your server setup with your host.
1 Create an S3 bucket for public media
Create a dedicated bucket with a globally unique name. These commands use Sydney, ap-southeast-2. Run them on your computer with an admin AWS profile. Keep admin keys off the web server.
# Create a dedicated Sydney bucket using your administrative AWS profile.
export B=example-site-wp-media
export R=ap-southeast-2
aws s3api create-bucket --bucket "$B" --region "$R" \
--create-bucket-configuration LocationConstraint="$R"
aws s3api put-bucket-ownership-controls --bucket "$B" \
--ownership-controls 'Rules=[{ObjectOwnership=BucketOwnerEnforced}]'
Allow public reads through a bucket policy, while keeping public ACLs blocked. If account or organisation rules prevent this, use an approved delivery setup rather than changing those rules.
# Allow policy-based public reads while keeping public ACLs blocked.
aws s3api put-public-access-block --bucket "$B" \
--public-access-block-configuration \
'BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=false,RestrictPublicBuckets=false'
Save this as public-media-policy.json and replace the bucket name. Every file under this prefix will be public.
{
"Version": "2012-10-17",
"Statement": [{
"Sid": "PublicReadMedia",
"Effect": "Allow",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::example-site-wp-media/wp-content/uploads/*"
}]
}
# Apply the public policy to this dedicated media bucket.
aws s3api put-bucket-policy --bucket "$B" \
--policy file://public-media-policy.json
Keep private uploads out of this bucket. This policy cannot protect individual downloads.
Test a new upload with your installed plugin version before copying the library. Bucket ownership reference: https://deliciousbrains.com/wp-offload-media/doc/amazon-s3-bucket-object-ownership/
2 Restrict access and enable versioning
Limit the server’s IAM policy to arn:aws:s3:::example-site-wp-media/wp-content/uploads/*, plus the bucket-level permissions the plugin needs. Start with its custom IAM policy guide: https://deliciousbrains.com/wp-offload-media/doc/custom-iam-policy-for-amazon-s3/
Use an IAM role if your host supports one, or a dedicated access key. Allow the read, list, write and multipart operations needed for copying, plus object deletion if the plugin needs it. Do not allow bucket-policy changes, versioning changes or permanent deletion of object versions.
# Enable recoverable object versions from your trusted admin session.
aws s3api put-bucket-versioning --bucket "$B" \
--versioning-configuration Status=Enabled
Add a lifecycle rule to clear incomplete multipart uploads after 7 days. You can expire old versions after 90 days to limit storage use, but that also limits how far back you can recover.
Versioning helps recover deleted or overwritten files. Keep a separate backup and test a restore too. AWS versioning reference: https://docs.aws.amazon.com/AmazonS3/latest/userguide/Versioning.html
3 Copy the existing library with the AWS CLI
Install AWS CLI v2 for your server’s operating system and CPU. Configure it as the site’s system user with the restricted credentials. Installation instructions: https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html
# Configure the restricted credentials and confirm access to the media bucket.
aws configure
aws s3 ls s3://example-site-wp-media/
Run this from the WordPress root. Review the dry run and add exclusions for your site’s private folders. The example exclusions are not a complete list. –no-follow-symlinks keeps linked files out of the copy.
# Preview the files to copy, adding site-specific private folder exclusions.
aws s3 sync wp-content/uploads/ \
s3://example-site-wp-media/wp-content/uploads/ \
--exclude "gravity_forms/*" \
--exclude "woocommerce_uploads/*" \
--exclude "wc-logs/*" \
--exclude "ithemes-security/*" \
--exclude "facebook_for_woocommerce/*" \
--no-follow-symlinks \
--cache-control "public, max-age=86400" \
--dryrun
Once the list is right, repeat without –dryrun in a persistent terminal session. Check the exit status and output when it finishes. Leave out –delete.
Use a one-day cache lifetime during testing. Longer caching needs reliable cache-busting URLs. Sync will not update headers on files it skips.
Compare copied keys and sizes against the intended local files, allowing for exclusions. Use checksums where supported, and open a few originals and thumbnails over HTTPS.
# Summarise copied objects and check a known public image.
aws s3 ls s3://example-site-wp-media/wp-content/uploads/ \
--recursive --summarize | tail -2
curl -I https://example-site-wp-media.s3.ap-southeast-2.amazonaws.com/wp-content/uploads/2024/05/photo.jpg
Use a real image path for that check. Expect HTTP 200 and the correct content type. CLI reference: https://docs.aws.amazon.com/cli/latest/reference/s3/sync.html
4 Set up WP Offload Media Lite for new uploads
Install WP Offload Media Lite and connect the bucket. You can put the restricted keys in wp-config.php; protect that file and its backups.
// Configure AWS access for WP Offload Media using a restricted key.
define( 'AS3CF_SETTINGS', serialize( array(
'provider' => 'aws',
'access-key-id' => 'YOUR_RESTRICTED_ACCESS_KEY',
'secret-access-key' => 'YOUR_RESTRICTED_SECRET_KEY',
) ) );
| Setting | Value for this walkthrough |
|---|---|
| Add Prefix to Bucket Path | On: wp-content/uploads/ |
| Add Year & Month to Bucket Path | On, matching the existing dated paths |
| Add Object Version to Bucket Path | Off |
| Remove Local Media | Off during migration and testing |
Upload a test image and check the original and generated sizes in S3. Keep private form uploads outside this process. Pause uploads and image-processing jobs for the final sync.
5 Update existing attachment URLs
The must-use plugin below changes attachment URLs, image sources and srcset. It only matches your old upload address and dated folders. Local file paths stay the same.
Save it as wp-content/mu-plugins/thrive-s3-media-urls.php and change both addresses. Create the directory if needed. Only use this mapping when every matching dated path is public and copied. Otherwise, use an attachment allowlist.
<?php
/** Serve verified public dated media from S3 while preserving local file paths. */
// Map only the exact old upload URL and the audited year/month layout.
function thrive_s3_public_media_url( $url ) {
$local = 'https://example.com.au/wp-content/uploads/';
$cloud = 'https://example-site-wp-media.s3.ap-southeast-2.amazonaws.com/wp-content/uploads/';
if ( ! is_string( $url ) || strpos( $url, $local ) !== 0 ) {
return $url;
}
$relative = substr( $url, strlen( $local ) );
if ( ! preg_match( '~^[0-9]{4}/(?:0[1-9]|1[0-2])/[^/].*~', $relative ) ) {
return $url;
}
return $cloud . $relative;
}
add_filter( 'wp_get_attachment_url', 'thrive_s3_public_media_url', 99 );
// Update the selected image size without changing its dimensions.
function thrive_s3_public_image_src( $image ) {
if ( is_array( $image ) && isset( $image[0] ) ) {
$image[0] = thrive_s3_public_media_url( $image[0] );
}
return $image;
}
add_filter( 'wp_get_attachment_image_src', 'thrive_s3_public_image_src', 99 );
// Update each responsive image candidate generated by WordPress.
function thrive_s3_public_srcset( $sources ) {
if ( is_array( $sources ) ) {
foreach ( $sources as &$source ) {
if ( isset( $source['url'] ) ) {
$source['url'] = thrive_s3_public_media_url( $source['url'] );
}
}
unset( $source );
}
return $sources;
}
add_filter( 'wp_calculate_image_srcset', 'thrive_s3_public_srcset', 99 );
Test galleries, ACF images, REST responses and optimisation plugins. This code does not cover hardcoded content, CSS backgrounds or every page builder.
6 Replace hardcoded URLs
Back up the database outside the public web root. Use WP-CLI’s search and replace to handle serialised data. Replace only confirmed public paths, not the whole uploads address.
# Export a private database backup before making content changes.
wp db export /secure-backups/pre-s3.sql
# Preview changes for one audited public month in content and custom fields.
wp search-replace \
'https://example.com.au/wp-content/uploads/2024/05/' \
'https://example-site-wp-media.s3.ap-southeast-2.amazonaws.com/wp-content/uploads/2024/05/' \
wp_posts wp_postmeta --skip-columns=guid --dry-run
Create the protected backup directory first and use your actual table prefix instead of wp_. Review the dry run, then repeat without –dry-run for each public folder. For folders with mixed access, replace exact public URLs instead.
Check old domains, HTTP URLs, builder data, options and custom tables separately. Regenerate builder assets if needed, and record the replacements so you can reverse them. WP-CLI reference: https://developer.wordpress.org/cli/commands/search-replace/
7 Test before removing local files
Run the final sync, check it finishes and clear all caches. In browser developer tools, check responsive images, mobile sizes and backgrounds.
- Old posts, galleries, featured images and ACF images load from S3 with HTTP 200.
- New uploads and edits work at every generated size and format.
- Private form files and protected downloads stay private.
- Old URLs still work for external links and Google Images.
- You can restore files from the separate media backup.
Keep local files until testing is complete. Lite does not manage the manually copied attachments. Cropping, optimisation and thumbnail regeneration may need local originals. Later edits may need another sync, and deleting old media in WordPress may leave files in S3.
To free disk space, remove only verified public files and have a way to restore originals for editing. Do not delete the whole uploads directory. Lite’s Remove Local Media setting does not manage the CLI-copied files.
Before removing files, set up permanent redirects from their old URLs to S3. Limit redirects to confirmed public paths and test with a disposable file. Ask your host whether the rules belong in nginx, Apache or your CDN.
Only exclude offloaded media from server backups once the separate S3 backup works. Keep backing up local private uploads.
How to roll back
With local files intact, disable the URL plugin, reverse the recorded replacements or restore the database, then clear caches. Handle plugin-managed uploads separately. Restore any S3-only uploads locally before changing their URLs back. Disabling the URL plugin does not undo database changes.
A few final notes
If images still load locally, check cached pages, hardcoded URLs, CSS backgrounds, old domains and plugin-generated formats. Copying files to S3 does not update those references.
CloudFront is optional for this setup. It adds CDN delivery and can use a private S3 origin, but needs a separate access policy and configuration.
Need help with a large WordPress media library?
A manual migration suits a predictable public library. If editors often crop or replace old images, we’d usually choose plugin-managed offloading.
Moving the whole site? See our command-line migration guide: https://thriveweb.com.au/the-lab/quickly-migrate-away-cpanel-command-line-using-ssh-rsync-wp-cli/
For help with media, backups or hosting: https://thriveweb.com.au/what-we-do/wordpress-hosting/