How to setup free CDN for WordPress using AWS S3

Looking for a free CDN service for your WordPress website? Use this guide to host all your static assets in AWS S3 for under $1/mo.

Table of Contents


Hello Folks!

A few years back, I was looking for a solution to host my WordPress blog static assets. I had only 3 requirements

  1. It has to be 99.99% SLA/Uptime
  2. It should be easy to implement 
  3. It has to be budget-friendly.

I started searching on Google and come across several solutions, but none of them had all the above 3 requirements. So I thought of using Amazon Web Services Simple Storage Service, AWS S3 in short ;). 

✅ On paper, they promise to provide 98-99.99% SLA.

✅ There are several 3rd party plugins available that can do the heavy lifting of implementations.

✅ It is too budget-friendly, and costs a pinch in a pocket.

Creating a public bucket in AWS S3

Before going into details, you need to have an active AWS account. If you do not have it already then you check this official doc as to how to do the same.

  • Go to S3 home page.
  • Click on the "Create bucket" button.
  • In the "Bucket name" field chose an FQDN that you want to use for an assets URL.
  • From "AWS Region" pick the region from where you are expecting more traffic. If you are not sure then you can pick any US Data-center which is cost-effective.
  • Make the bucket public by unchecking "Block all public access".
  • Click on the "Create bucket" button at the bottom.
AWS > Create bucket > General configuration
AWS > Create bucket > Block Public Access settings for this bucket

Setting up a bucket & CORS policy

Now you have to set a bucket policy so that its objects or content can be accessed publicly. To do this, you have to navigate to the bucket "Permissions" tab and scroll to the bucket policy area and click on "Edit".
Now replace the content with the following JSON, but do not forget to replace the __ARN_AWS_NAME__ placeholder with your Bucket's ARN name.

    "Version": "2012-10-17",
    "Statement": [
            "Effect": "Allow",
            "Principal" : { "AWS" : "*" },
            "Action": ["s3:GetObject"],
            "Resource": "__ARN_AWS_NAME__/*"
Bucket > Permissions > Bucket policy
Bucket > Permissions > Bucket policy
Bucket > Permissions > Bucket policy > Editor
Bucket > Permissions > Bucket policy > Editor

Now you have to set the Cross-origin resource sharing (CORS) policy so that no one can use your assets from any other domain(s), except the ones which are whitelisted. To do so you have to navigate to the bucket permission tab and go to CORS policy and click on edit.
Add the following JSON, just make sure to update the __MY_WEBSITE__ placeholder with your domain name

"AllowedHeaders": ["*"],
"AllowedMethods": [
"AllowedOrigins": [
"ExposeHeaders": [],
"MaxAgeSeconds": 86400
"AllowedHeaders": [],
"AllowedMethods": [
"AllowedOrigins": ["*"],
"ExposeHeaders": [],
"MaxAgeSeconds": 86400

Bucket > Permissions > CORS policy
Bucket > Permissions > CORS policy
Bucket > Permissions > CORS policy > Editor
Bucket > Permissions > CORS policy > Editor

Creating an IAM user for managing images

Now an API user is required which will manage your assets from WordPress via API, like uploading, deleting, etc. Go to the AWS IAM section.

  • Fill in a username.
  • Select an "Access key - Programmatic access" from the AWS credential type.
  • Click on "Next: Permissions"
IAM User > Add user > Step 1 (Name)
IAM User > Add user > Step 1 (Name)
  • Click the “Attach existing policies directly” button, and then enter "AmazonS3FullAccess" in the filter policies input box.
  • Select the "AmazonS3FullAccess" policy.
  • Click the "Next: Tags" button at the bottom of the page.
IAM User > Add user > Step 2 (Permissions)
IAM User > Add user > Step 2 (Permissions)
  • Click on "Next: Review"
  • Click on "Create user"
IAM User > Add user > Step 5 (Credential)
IAM User > Add user > Step 5 (Credential)

Add the credentials in wp-config.php file

define('AS3CF_SETTINGS', serialize([
'provider' => 'aws',
'access-key-id' => '*********',
'secret-access-key' => '********************',

Pointing of custom domain to S3

If you do not want to use the Amazon URL in your website, then you can use a custom domain as well to point to your bucker which in return will point to your assets.
To achieve the same you have to add a CNAME in your domain registrar like this.

; Syntax :


; Example:

Bucket > Properties
Bucket > Properties

Setting up WP Offload Media Lite plugin

Install and activate WP Offload Media Lite for Amazon S3, DigitalOcean Spaces, and Google Cloud Storage plugin from

The setup is very much straightforward. 

  • As you have already defined the credentials in wp-config.php in the previous step so the provider will get auto-selected.
  • Fill the bucket name
  • Enable copy file to the bucket
  • Provide the assets path as "wp-content/uploads/"
  • Enable "Year/Month" if your images are organized in year and month format
  • Select the delivery provider
  • Provide the custom domain name
  • Enable Force HTTPS

That's it!

Syncing of existing images in Amazon S3

If your site is new or you do not have any existing post images then you can skip this step. But if you have existing images then if you see those images you will see they are getting served from your server not from the S3 bucket the reason is as you are using the free version of the plugin so the syncing of old images is not enabled for that you have to use the paid version. But no worries, I have a ninja hack for that. You have to follow these steps

  • Upload your whole "wp-content/uploads/" folder into the Amazon S3 bucket which you had created.
  • Run the following function wh_syncWPMediaLiteTable till you see a message like this: "All the images are synced into WP Offload Media Lite table.". You can create a custom template and call it over and over again or set up a WordPress CORN as described in this article to achieve the same.


if (!function_exists('wh_syncWPMediaLiteTable')) {

function wh_syncWPMediaLiteTable() {

global $wpdb;

$wpUploadDir = rtrim(wp_get_upload_dir()['basedir'], '/');
$itemsTable = 'as3cf_items';

$offloadSettings = get_option('tantan_wordpress_s3');
$lastAttachmentID = get_option('_site_transient_wh_last_sync_attachment', 0);

$objectPrefix = rtrim($offloadSettings['object-prefix'], '/');

$format = [
'provider' => '%s',
'region' => '%s',
'bucket' => '%s',
'path' => '%s',
'original_path' => '%s',
'is_private' => '%d',
'source_type' => '%s',
'source_id' => '%d',
'source_path' => '%s',
'original_source_path' => '%s',
'extra_info' => '%s',
'originator' => '%d',
'is_verified' => '%d'
$format = array_values($format);

#Getting the list of attachments
$sql = $wpdb->prepare("SELECT p.`ID`,
MAX(CASE WHEN pm.meta_key = '_wp_attached_file' THEN pm.meta_value END ) as 'attached_file',
MAX(CASE WHEN pm.meta_key = '_wp_attachment_metadata' THEN pm.meta_value END ) as 'attachment_metadata',
FROM `{$wpdb->prefix}posts` AS p
LEFT JOIN `{$wpdb->prefix}postmeta` AS pm ON p.`ID` = pm.`post_id`
LEFT JOIN `{$wpdb->prefix}{$itemsTable}` AS ai ON p.`ID` = ai.`source_id`
AND ai.`source_id` IS NULL
WHERE p.`post_type` = %s
AND p.`ID` > %d
LIMIT %d", 'attachment', $lastAttachmentID, 10);

$attachments = $wpdb->get_results($sql);

if (empty($attachments)) {
#trigger a mail to site admin that no attachments are left
die('All the images are synced into WP Offload Media Lite table.');
//return false;
// print_r($attachments);

foreach ($attachments as $key => $attachment) {
$lastAttachmentID = $attachment->ID;
$attachmentMetadata = maybe_unserialize($attachment->attachment_metadata);

$fullFilePath = $wpUploadDir . '/' . $attachmentMetadata['file'];

#Checking image exists or not if not then no point of adding it into WP Offload Media's table
if (!file_exists($fullFilePath)) {

#preparing data
$data = [
'provider' => 'aws',
'region' => $offloadSettings['region'],
'bucket' => $offloadSettings['bucket'],
'path' => $objectPrefix . '/' . $attachment->attached_file,
'original_path' => $objectPrefix . '/' . $attachment->attached_file,
'is_private' => 0,
'source_type' => 'media-library',
'source_id' => $attachment->ID,
'source_path' => $attachment->attached_file,
'original_source_path' => $attachment->attached_file,
'extra_info' => 'a:2:{s:13:"private_sizes";a:0:{}s:14:"private_prefix";s:0:"";}',
'originator' => 0,
'is_verified' => 1
#Adding record into WP Offload Media's table
$wpdb->insert("{$wpdb->prefix}{$itemsTable}", $data, $format);
update_option('_site_transient_wh_last_sync_attachment', $lastAttachmentID, false);

Setting up a budget alert in AWS S3

It is always recommended to set up an alert on your cloud service usage, you never know when you have a spike in your traffic and your card was charged for it. You should always keep a track of usage like it is from real traffic or a bot or something. As I am using the S3 to only serve the blog post images I have set the budget to $2. Till now I have been into the AWS free tier limit so technically I have been using it for free.
Create a recurring monthly budget of type "Cost budget" by giving it a meaningful name, price & selecting the S3 filter. In the next step set the desired threshold and the email where you want to get the alert.
That's it! If you want to read more on budgeting alert you can read this AWS official article.

Billing Management Console > Budgets > Create budget > Step 1 (Types)
Billing Management Console > Budgets > Create budget > Step 1 (Types)
Billing Management Console > Budgets > Create budget > Step 2 (Details)
Billing Management Console > Budgets > Create budget > Step 2 (Details)
Billing Management Console > Budgets > Create budget > Step 3 (Alert)
Billing Management Console > Budgets > Create budget > Step 3 (Alert)

Now you can sit back and relax and see how your website is performing on site-speed matrices as you have reduced the number of server requests. On top of that, you have also implemented AWS S3 bucket CDN.

One can say the AWS S3 bucket cannot be termed as fully CDN as the data are getting served from a specific location. That is partially true but you know Amazon has a default caching mechanism that caches the resource to the nearest data center from where the request was made.

Let me know in the comments if you face any issues while implementing it will be more than happy to help you. Also, let me know how much you are saving after the implementation.

Raunak Gupta

Raunak Gupta

I'm Raunak Gupta, a seasoned software developer with over 9 years of experience in a wide range of programming languages, frameworks, and tools. I started my journey as a WordPress & CakePHP developer in 2014, diving deep into the world of OOPs, Request handling, and SEO. Along the way, I crafted numerous dazzling WooCommerce stores, tamed payment gateways, optimized for full filament functionality, and achieved ultra-low latency for lightning-fast load times. My expertise extends to BI tools, website builders, DevOps, and team leadership. I like to help upcoming developers, so I share my experience through this blog and by assisting fellow developers on Stack Overflow, where I've earned a stellar reputation with over 10k+ points of recognition.

Articles: 29


  1. Ha ha, that’s a proper clever hack you figured out there, mate! ‘Ow did you come to know about this? Did you ‘ave a premium access or somethin’?

Leave a Reply

Your email address will not be published. Required fields are marked *