Offloading WordPress media to Amazon S3 and serving it through CloudFront is a solid way to reduce load on your web server, improve media delivery, and keep your uploads organized outside the local filesystem. CloudFront can be configured to securely read from a private S3 bucket by using Origin Access Control, which AWS recommends for S3 origins. AWS also recommends disabling ACLs in modern S3 setups and using policies instead. (AWS Documentation)
This guide walks through the full setup:
- Create the S3 bucket
- Create the IAM user and policy
- Create the CloudFront distribution
- Create the media subdomain CNAME
- Add the bucket policy
- Configure Advanced Media Offloader in WordPress
- Test and deploy
Advanced Media Offloader is built to offload WordPress media to S3-compatible storage and rewrite media URLs so files can be served from a CDN or storage domain. (WordPress.org)
Before You Start
You will need:
- an AWS account
- a WordPress site with Advanced Media Offloader installed
- access to your DNS provider
- access to your site’s wp-config.php
For the examples below, I’ll use these placeholders:
- bucket name: clientsite-media
- IAM user: clientsite-media-user
- media subdomain: media.clientsite.com
- region: us-east-2
Step 1: Create the S3 Bucket
In Amazon S3, create a new bucket.
Use a unique bucket name such as clientsite-media, pick the correct AWS region, and finish the bucket creation. For modern S3 setups, AWS recommends keeping ACLs disabled and using Object Ownership in bucket-owner-enforced mode. That keeps permissions policy-based instead of ACL-based. (AWS Documentation)
After the bucket is created, make note of:
- bucket name
- region
You will need both later in the WordPress config.
Step 2: Create the IAM User
In IAM, create a dedicated user for this one site.
Use a name like clientsite-media-user. Do not give the user console access. Create an access key for programmatic use.
The goal here is simple: one site, one IAM user, one bucket-specific policy. Do not attach broad policies like AmazonS3FullAccess unless you are only troubleshooting and plan to remove it immediately afterward.
Attach a Bucket-Specific IAM Policy
Attach a custom policy like this and replace the bucket name with your actual bucket:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "BucketAccess",
"Effect": "Allow",
"Action": [
"s3:ListBucket",
"s3:GetBucketLocation"
],
"Resource": "arn:aws:s3:::clientsite-media"
},
{
"Sid": "ObjectAccess",
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:PutObject",
"s3:PutObjectAcl",
"s3:DeleteObject",
"s3:AbortMultipartUpload",
"s3:ListMultipartUploadParts"
],
"Resource": "arn:aws:s3:::clientsite-media/*"
}
]
}This gives the plugin access only to the one bucket and its objects.
Save the following when the IAM user is created:
- Access Key ID
- Secret Access Key
AWS documents that S3 permissions are controlled through IAM and bucket policies, and bucket-level actions and object-level actions are scoped to different resource ARNs. (AWS Documentation)
Important Note About ACLs
If you build this as a fully modern private S3 setup with ACLs disabled, requests that try to set ACLs can fail with AccessControlListNotSupported. AWS documents that behavior directly. (AWS Documentation)
In practice, if your plugin setup requires s3:PutObjectAcl, you may need to keep that permission in place. The exact behavior depends on how the plugin is writing objects.
Step 3: Create the CloudFront Distribution
In CloudFront, create a new distribution.
Choose:
- Origin type: Amazon S3
- Origin: your S3 bucket
- Origin path: leave blank
For a private S3 origin, AWS recommends using Origin Access Control rather than the older Origin Access Identity model. CloudFront can then read from the S3 bucket without making the bucket publicly readable. (AWS Documentation)
For the rest of the basic setup:
- use the recommended origin settings
- use the recommended cache settings for S3 content
- set viewer protocol policy to redirect HTTP to HTTPS
Finish creating the distribution.
After deployment, CloudFront will give you a distribution domain that looks like this:
d123abc456def.cloudfront.net
That can be used immediately, or you can point a cleaner subdomain to it.
Step 4: Create the Media Subdomain CNAME
This is the step that often gets skipped in basic guides.
If you want your media to load from a branded URL like media.clientsite.com instead of the default CloudFront domain, create a CNAME record in your DNS.
DNS Record Example
At your DNS provider, create:
- Type: CNAME
- Name / Host: media
- Target / Points to: d123abc456def.cloudfront.net
That creates:
media.clientsite.com -> d123abc456def.cloudfront.net
Then Update CloudFront
Inside the CloudFront distribution:
- add media.clientsite.com as an alternate domain name
- attach an SSL certificate that covers media.clientsite.com
If the SSL certificate is not in place, the custom media domain will not work correctly over HTTPS.
AWS documents that CloudFront can serve private content securely and that access to S3 content should be restricted through CloudFront when using this model. (AWS Documentation)
Step 5: Add the Bucket Policy for CloudFront
Once the distribution exists, the S3 bucket must explicitly allow CloudFront to read from it.
Use a bucket policy like this and replace:
- bucket name
- AWS account ID
- CloudFront distribution ID
{
"Version": "2008-10-17",
"Id": "PolicyForCloudFrontPrivateContent",
"Statement": [
{
"Sid": "AllowCloudFrontServicePrincipal",
"Effect": "Allow",
"Principal": {
"Service": "cloudfront.amazonaws.com"
},
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::clientsite-media/*",
"Condition": {
"ArnLike": {
"AWS:SourceArn": "arn:aws:cloudfront::123456789012:distribution/EXAMPLEDISTRO"
}
}
}
]
}This policy allows only that CloudFront distribution to read objects from the bucket. AWS’s documentation for restricting access to an S3 origin through CloudFront uses this same general model. (AWS Documentation)
Step 6: Configure Advanced Media Offloader in WordPress
Advanced Media Offloader supports S3-compatible storage and URL rewriting for media delivery. (WordPress.org)
Add your settings to wp-config.php, above the line that says:
/* That’s all, stop editing! Happy publishing. */
Use this format:
define( ‘ADVMO_AWS_KEY’, ‘YOUR_ACCESS_KEY’ );
define( ‘ADVMO_AWS_SECRET’, ‘YOUR_SECRET_KEY’ );
define( ‘ADVMO_AWS_BUCKET’, ‘clientsite-media’ );
define( ‘ADVMO_AWS_REGION’, ‘us-east-2’ );
define( ‘ADVMO_AWS_DOMAIN’, ‘https://media.clientsite.com’ );
If you are not using a custom subdomain yet, use the default CloudFront domain instead:
define( ‘ADVMO_AWS_DOMAIN’, ‘https://d123abc456def.cloudfront.net’ );
Make sure:
- the bucket name matches exactly
- the region matches exactly
- the domain has no trailing slash
Step 7: Deploy and Test
Once the bucket, IAM user, CloudFront distribution, DNS, bucket policy, and plugin config are all in place, test the setup with a brand new image upload in WordPress.
You want to confirm three things:
- the image uploads successfully in WordPress
- the file appears in the S3 bucket
- the image URL loads from the CloudFront or custom media domain
If the image is in S3 but does not load on the site, the issue is usually one of these:
- CloudFront is not fully deployed yet
- the CNAME is not pointed correctly
- the alternate domain is missing in CloudFront
- the SSL certificate is not attached
- the domain in wp-config.php is wrong
If the plugin connects to AWS but fails to offload, the problem is usually one of these:
- wrong bucket name
- wrong region
- missing IAM permission
- ACL mismatch
- overly broad testing permissions were removed before the correct scoped ones were added
Step 8: Tighten Permissions Before You Walk Away
Once the setup is working, make sure the IAM user does not still have broad policies attached.
The IAM user should not keep AmazonS3FullAccess. It should only have the bucket-specific policy needed for that one site.
This matters because broad S3 access means one leaked key can affect every bucket in the account. AWS’s IAM guidance favors tightly scoped access rather than broad long-term credentials. (AWS Documentation)
Common Troubleshooting Notes
If uploads fail with s3:PutObjectAcl
The plugin is trying to write an ACL to the object. Either the IAM policy is missing s3:PutObjectAcl, or the bucket is configured with ACLs disabled and the plugin is still attempting ACL operations. AWS documents that ACL-setting requests fail when ACLs are disabled. (AWS Documentation)
If CloudFront works but direct S3 URLs do not
That is normal in a private S3 plus CloudFront setup. CloudFront is supposed to be the public-facing layer, not the bucket itself. (AWS Documentation)
If you want the most modern S3 setup
AWS recommends using Origin Access Control for CloudFront and disabling ACLs with bucket-owner-enforced object ownership where possible. (AWS Documentation)
Final Checklist
Before calling the setup done, make sure all of this is true:
- S3 bucket exists
- region is correct
- IAM user exists with a bucket-specific policy
- access key and secret are saved
- CloudFront distribution is deployed
- media subdomain CNAME points to CloudFront
- alternate domain is added in CloudFront
- SSL certificate is attached
- bucket policy allows CloudFront access
- wp-config.php contains the plugin constants
- a new image upload offloads successfully
- the image loads from the media subdomain
Conclusion
Once you’ve done this a couple times, the process is pretty repeatable. The real trouble spots are always the same: IAM policy scope, CloudFront-to-S3 permissions, the custom media subdomain, and plugin config. Get those four right and the rest usually falls into place.

