This document outlines the Linux-specific configuration required to support WordPress integration with AWS (S3 + CloudFront) using an IAM user and local AWS credentials.
Linux Configuration (nginx / PHP 8.4-FPM / Debian 13 – Linode)
We assert that your Linux/WordPress configurations are running. The following information outlines with specific examples further configurations to support the AWS/PHP coding.
Some Advice
With the public adoption of AI code programming, we are able to work with more complex scripting and hence added functionality compared to our individual programming skills of the past.
This is a learning curve, as AI coding has well known issues we will soon see as we use it. However, once code is working, it is usually not working. We will find bugs under different circumstances in a live environment. Further, we will not always understand what the code is doing. It is therefore helpful to ask AI with any program to provide an inventory and explanation of what the code is doing in sections at a high level view, and then step by steps if you wish to go further with those details.
It is also helpful to ask AI to provide you a summary of what you are doing as a document you can cut and paste back into AI at a later time in order to continue the work.
Linux Specification
This is a structured, implementation-ready document that aligns with the AWS/CDN spec.
WordPress Media Library CDN Integration
Linux Configuration (nginx / PHP 8.4-FPM / Debian 13 – Linode)
1. Overview
This document outlines the Linux-specific configuration required to support WordPress integration with AWS (S3 + CloudFront) using an IAM user and local AWS credentials.
It assumes:
- Base system setup is already complete (nginx, PHP-FPM, MariaDB, etc.)
- WordPress is installed and functioning
- AWS infrastructure (S3, CloudFront, IAM) is already configured
This document focuses specifically on:
- AWS credential access from nginx/PHP
- PHP-FPM (
www.conf) configuration - File permissions and security
- Logging and debugging
2. Key Principle
On Linode (or any non-EC2 host):
PHP runs as the nginx user, so AWS credentials must be accessible to the nginx user.
Unlike EC2:
- ❌ No IAM Roles available
- ✅ Must use
~/.aws/credentials
3. nginx User Home Directory
3.1 Check Existing Configuration
grep nginx /etc/passwd
You may see that nginx has no home directory defined.
3.2 Create nginx Home Directory
If no home exists:
cd /home
mkdir -p /home/nginx
chown nginx:nginx /home/nginx
usermod -d /home/nginx nginx
3.3 Verify
ls -ld /home/nginx
Expected:
drwxr-xr-x nginx nginx ...
4. AWS Credentials Configuration
4.1 Create .aws Directory
cd /home/nginx
mkdir .aws
Set secure permissions:
chmod 700 /home/nginx/.aws
chown nginx:nginx /home/nginx/.aws
4.2 Create AWS Config File
vi /home/nginx/.aws/config
[profile wordpress-media]
region = ap-southeast-2
output = json
4.3 Create Credentials File
vi /home/nginx/.aws/credentials
[wordpress-media]
aws_access_key_id = ACCESS_KEY
aws_secret_access_key = SECRET_KEY
4.4 Set Secure Permissions
chown -R nginx:nginx /home/nginx
chmod 755 /home/nginx
chmod 700 /home/nginx/.aws
chmod 600 /home/nginx/.aws/config
chmod 600 /home/nginx/.aws/credentials
Important Notes
- Permissions must be strict, or AWS SDK may refuse access
- Profile name (
wordpress-media) matches IAM user for clarity - Multiple profiles can exist, but only relevant ones should be accessible
5. PHP-FPM Configuration (www.conf)
This is the critical integration point between PHP and AWS.
File location typically:
/etc/php/8.4/fpm/pool.d/www.conf
5.1 Allow URL fopen
php_admin_flag[allow_url_fopen] = on
5.2 Configure open_basedir
This must include all required paths:
php_admin_value[open_basedir] = /var/www:/var/www/html:/tmp:/usr/bin:/usr/local/bin/aws:/usr/share/phpMyAdmin:/home/nginx/.aws
Explanation
| Path | Purpose |
|---|---|
/var/www/html |
WordPress root |
/var/www |
Shared assets / SDKs |
/tmp |
Temporary files |
/usr/local/bin/aws |
AWS CLI |
/usr/share/phpMyAdmin |
phpMyAdmin |
/home/nginx/.aws |
AWS credentials |
Adjust paths depending on your environment.
5.3 Enable Required PHP Functions
Default may block required execution:
; php_admin_value[disable_functions] = exec,passthru,system
php_admin_value[disable_functions] = passthru,system
This allows:
exec()(required for some AWS interactions or scripts)
6. PHP Logging Configuration
Logging is essential for debugging AWS integration.
6.1 Configure Global PHP Error Log
echo "error_log = /var/log/php_errors.log" | sudo tee /etc/php/8.4/fpm/conf.d/99-errorlog.ini
6.2 Create Log File
touch /var/log/php_errors.log
chown nginx:nginx /var/log/php_errors.log
6.3 Update www.conf
Replace default:
; php_admin_value[error_log] = /var/log/php8.4-fpm.log
php_admin_value[error_log] = /var/log/php_errors.log
6.4 Notes
- Some Debian systems default to different log paths
- If logging does not work:
- Ensure file exists (
touch) - Check permissions
- Restart PHP-FPM
- Ensure file exists (
7. Restart Services
After changes:
systemctl restart php8.4-fpm
systemctl restart nginx
8. Validation & Testing
Before integrating with WordPress, confirm AWS access works.
8.1 Basic Test Strategy
Use a simple PHP script or CLI test to:
- List bucket contents
- Upload a test file (e.g.
test.txt) - Delete the test file
8.2 Key Things to Confirm
- AWS credentials are being read
- Permissions allow S3 operations
- No PHP errors in logs
- No
open_basedirrestrictions triggered
9. Common Issues & Gotchas
9.1 Permissions Problems
- Incorrect
.awspermissions → AWS SDK fails silently or throws errors
9.2 open_basedir Errors
- Missing
/home/nginx/.aws→ credentials not accessible (e.g. on EC2 Linux2023 it may be /var/lib/nginx/.aws)
9.3 Disabled PHP Functions
exec()disabled → integration failures
9.4 Logging Not Working
- Log file does not exist
- Incorrect ownership
9.5 nginx User Context
- CLI tests as root may work, but fail via PHP (nginx user)
10. Security Considerations
This setup is practical but not ideal compared to AWS-native environments.
Current Approach (Linode)
- Uses IAM user credentials stored on disk
- Secured via filesystem permissions
Risks
- Credential exposure if server compromised
- Broader access than IAM Role-based approach
Recommended Future Improvements
- Move to EC2 with IAM Role
- Restrict IAM policy by IP (
LINODE_IPV4) - Rotate access keys regularly
- Limit permissions further if possible
11. Key Takeaways
- nginx must have a valid home directory for AWS credentials
.awspermissions are criticalwww.confmust explicitly allow access to:- AWS credentials
- required binaries
- Logging must be working before debugging anything else
- Always test AWS access outside WordPress first
12. Next Steps
With Linux and AWS now configured:
➡️ Next document:
- WordPress mu-plugin (CDN integration code)
- Media upload handling
- URL rewriting to CDN
- Optional CloudFront invalidation logic
Some Advice
Our next steps need caution so that one does not block oneself from ever accessing the bucket and fixing the policy if a DENY mistake is made. CAUTION.
Restricting Access to Linux IP4 Static IP Address
IP-based restrictions on S3/IAM are useful, but they’re not bulletproof (especially behind NAT, proxies, or if your infrastructure changes). Treat this as defence-in-depth, not your primary control.
That said, for a single Linode host with a stable IPv4, it’s a very practical hardening step.
Enhancing Security with IPv4 Restrictions (Linode → AWS)
1. What We’re Restricting
You currently allow your IAM user (wordpress-media) to:
- Upload
- Read
- Delete
- List objects in S3
We will tighten this so:
✅ Only requests originating from
LINODE_IPV4are allowed
2. Important Considerations
Before implementing:
✅ Works well when:
- Single server (your case)
- Static public IPv4
- No proxying (e.g. not behind Cloudflare for uploads)
⚠️ Watch out for:
- Future server migrations
- CI/CD systems needing access
- Local testing (will fail unless allowed)
3. Option A (Recommended): Restrict in S3 Bucket Policy
This is the best place to enforce IP restrictions.
🔧 Update: AllowWordPressMediaManagement Statement
Add a condition block:
{
"Sid": "AllowWordPressMediaManagement",
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::ACCOUNT_ID:user/wordpress-media"
},
"Action": [
"s3:PutObject",
"s3:GetObject",
"s3:DeleteObject",
"s3:ListBucket"
],
"Resource": [
"arn:aws:s3:::S3_BUCKET_NAME",
"arn:aws:s3:::S3_BUCKET_NAME/*"
],
"Condition": {
"IpAddress": {
"aws:SourceIp": "LINODE_IPV4/32"
}
}
}
🧠 What This Does
- Even if credentials are leaked → access denied unless from your server IP
- Applies regardless of how credentials are used (CLI, SDK, etc.)
4. Option B: Restrict IAM Policy (Additional Layer)
You can also apply the same restriction in the IAM policy:
{
"Sid": "WordPressMediaBucketAccess",
"Effect": "Allow",
"Action": [
"s3:PutObject",
"s3:GetObject",
"s3:DeleteObject",
"s3:ListBucket",
"s3:GetBucketLocation"
],
"Resource": [
"arn:aws:s3:::S3_BUCKET_NAME",
"arn:aws:s3:::S3_BUCKET_NAME/*"
],
"Condition": {
"IpAddress": {
"aws:SourceIp": "LINODE_IPV4/32"
}
}
}
🧠 Why Both?
| Layer | Purpose |
|---|---|
| S3 Bucket Policy | Resource-level enforcement |
| IAM Policy | Identity-level enforcement |
Using both is stronger, but bucket policy alone is usually sufficient.
5. Do NOT Apply This to CloudFront Access
Your existing statement:
"Principal": {
"Service": "cloudfront.amazonaws.com"
}
Must NOT have IP restrictions, because:
- CloudFront uses dynamic AWS edge IPs
- You will break CDN delivery
6. Testing Strategy (Important)
Step 1: Confirm Current IP
From your server:
curl ifconfig.me
Replace LINODE_IPV4 with this value.
Step 2: Apply Policy
- Update bucket policy
- Wait ~30 seconds
Step 3: Test from Server
✅ Should work:
- Upload file
- List bucket
- Delete file
Step 4: Test from Local Machine
❌ Should fail:
- Access denied (expected)
7. Optional: Allow Multiple IPs
If needed:
"IpAddress": {
"aws:SourceIp": [
"LINODE_IPV4/32",
"SECONDARY_IP/32"
]
}
8. Optional: Temporary Debug Access
During development, you might allow broader access:
"aws:SourceIp": "0.0.0.0/0"
⚠️ Only for testing—remove afterwards.
9. Alternative Approach (Future – Better)
When you move to EC2:
✅ Use IAM Roles instead of IP restrictions
Benefits:
- No stored credentials
- Automatically rotated
- Stronger security model
10. Recommendation Summary
For your current setup:
✅ Implement IP restriction in S3 bucket policy
✅ Optionally mirror it in IAM policy
❌ Do NOT restrict CloudFront access
⚠️ Keep a backup policy in case you lock yourself out
11. Safety Tip (Very Important)
Before applying changes:
- Keep a copy of the current working policy
- Apply changes in AWS Console (not CLI)
- Test immediately
If something breaks, revert quickly.
FURTHER TIGHTENING – WITH CAUTION
Great—let’s do this in a safe, staged way so you don’t accidentally lock yourself out while testing.
Safe Progressive Lockdown (S3 + IAM IP Restriction)
This approach lets you:
- Add IP restriction without breaking anything
- Verify it works
- Then enforce it strictly
Stage 1 – Add IP Restriction WITHOUT Breaking Access
Instead of immediately restricting access, we first allow both:
- your server IP ✅
- everything else (temporary) ⚠️
🔧 Temporary Bucket Policy (Safe Mode)
Modify your existing statement like this:
{
"Sid": "AllowWordPressMediaManagement",
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::ACCOUNT_ID:user/wordpress-media"
},
"Action": [
"s3:PutObject",
"s3:GetObject",
"s3:DeleteObject",
"s3:ListBucket"
],
"Resource": [
"arn:aws:s3:::S3_BUCKET_NAME",
"arn:aws:s3:::S3_BUCKET_NAME/*"
]
}
⬇️ Add this additional statement (do NOT replace yet):
{
"Sid": "AllowFromSpecificIP",
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::ACCOUNT_ID:user/wordpress-media"
},
"Action": "s3:*",
"Resource": [
"arn:aws:s3:::S3_BUCKET_NAME",
"arn:aws:s3:::S3_BUCKET_NAME/*"
],
"Condition": {
"IpAddress": {
"aws:SourceIp": "LINODE_IPV4/32"
}
}
}
🧠 Why This Works
- Original policy still allows everything → no lockout
- New policy confirms IP-based access works
- You can test safely before enforcing
Stage 2 – Verify Behaviour
✅ From Linode server:
- Upload → should work
- Delete → should work
- List → should work
✅ From local machine:
- Still works (because old policy still exists)
Stage 3 – Enforce Restriction (Lock It Down)
Once confirmed:
🔁 Replace original statement with IP-restricted version:
{
"Sid": "AllowWordPressMediaManagement",
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::ACCOUNT_ID:user/wordpress-media"
},
"Action": [
"s3:PutObject",
"s3:GetObject",
"s3:DeleteObject",
"s3:ListBucket"
],
"Resource": [
"arn:aws:s3:::S3_BUCKET_NAME",
"arn:aws:s3:::S3_BUCKET_NAME/*"
],
"Condition": {
"IpAddress": {
"aws:SourceIp": "LINODE_IPV4/32"
}
}
}
🧹 Then REMOVE the temporary statement:
AllowFromSpecificIP
Stage 4 – Confirm Lockdown
From Linode:
✅ Still works
From anywhere else:
❌ Access denied (this is correct)
Optional Hard Lock (Advanced)
If you want to be extra strict, you can add an explicit DENY:
{
"Sid": "DenyNonApprovedIP",
"Effect": "Deny",
"Principal": "*",
"Action": "s3:*",
"Resource": [
"arn:aws:s3:::S3_BUCKET_NAME",
"arn:aws:s3:::S3_BUCKET_NAME/*"
],
"Condition": {
"NotIpAddress": {
"aws:SourceIp": "LINODE_IPV4/32"
}
}
}
⚠️ Warning on DENY
- Deny overrides EVERYTHING (even admins)
- Only use after full testing
- Easy to lock yourself out
Rollback Plan (Important)
If something breaks:
- Go to AWS Console → S3 → Bucket Policy
- Remove the
Conditionblock - Save
Always keep a working copy locally before changes.
Practical Tip
Run this from your server before locking down:
curl ifconfig.me
Make sure it exactly matches:
LINODE_IPV4
Recommendation
For your setup:
✔ Use Stage 3 (IP-restricted allow) as your final state
❌ Avoid DENY unless you really need it
✔ Keep policies simple and readable
EXAMPLE of how to lock yourself out:
But you can lock yourself out of the S3 bucket (and its policy), which can be a real pain to recover from.
What You CAN’T Lock Yourself Out Of
Even with a bad bucket policy:
- ✅ You can still log into the AWS Console
- ✅ You can still access IAM
- ✅ Your root account still works
- ✅ Other services are unaffected
So you’re not “bricking” your AWS account 👍
What You CAN Lock Yourself Out Of
You can block access to:
- ❌ The S3 bucket contents
- ❌ The ability to modify the bucket policy
- ❌ Access via IAM users (including admins, if you’re not careful)
This happens because:
Explicit
Denyin a bucket policy overrides everything—even admin permissions
Example of a Problem Scenario
If you apply this:
“Condition”: {
“NotIpAddress”: {
“aws:SourceIp”: “LINODE_IPV4/32”
}
}
And then:
- Your IP changes
- You access AWS from your laptop
- Or AWS internally evaluates the request differently
👉 You can block your own access to the bucket
Can Root Fix It?
Usually: Yes
The AWS root account can typically:
- Go to S3 in the console
- Edit or remove the bucket policy
But… there’s a catch ⚠️
If the policy is written too aggressively, you can:
- Block all principals (
"Principal": "*") - Combine with
Deny - Apply it to both bucket and objects
In rare cases, this can make:
🔒 Even the console unable to access the bucket policy
How AWS Recovery Works (Worst Case)
If you truly lock it down badly:
- You may need to:
- Use root account via CLI
- Or contact AWS Support
This is why AWS docs always warn about Deny usage.
Why Your Current Approach Is Safe
Your setup (recommended):
✅ Safe
Allowwith IP restriction- No explicit
Deny
⚠️ Risky
- Adding global
DenywithNotIpAddress
Best Practice for Your Case
Given your environment (Linode + IAM user):
✔ Do this:
- Use IP-restricted Allow
- Keep policy simple
- Test in stages (as outlined earlier)
❌ Avoid for now:
- Global
Deny - Over-complicated conditions
Extra Safety Tip (Highly Recommended)
Before applying changes:
- Keep a known-good policy copy locally
- Apply changes in AWS Console (not CLI)
- Keep the browser tab open until confirmed working
- Test immediately from:
- server (should work)
- local machine (should fail)
Simple Rule of Thumb
If you’re not 100% sure about a
Denystatement—don’t use it yet.
Bottom Line
- You won’t lose AWS account access ✅
- You can lock yourself out of S3 ⚠️
- Recovery is usually possible, but inconvenient
- Your staged approach avoids this risk 👍
More information on SAFE HARDENING
Great question—and this is where things get subtle.
A “safe deny pattern” means:
You still use an explicit
Deny(strong security),
but you build in a guaranteed way back in so you don’t lock yourself out.
1. Why “Safe Deny” Matters
A normal deny like this:
"Effect": "Deny",
"Principal": "*"
👉 will override everything, including:
- your IAM admin users
- potentially even root via console (depending on conditions)
That’s where people get into trouble.
2. The Safe Deny Concept
The trick is:
✅ Deny everyone EXCEPT specific trusted principals
So instead of:
- “deny all except this IP”
You do:
- “deny all unless it’s from this IP OR from this trusted IAM identity”
3. Safe Deny Pattern (Recommended Version)
Here’s a practical and safe version for your setup:
{
"Sid": "DenyAllExceptApprovedAccess",
"Effect": "Deny",
"Principal": "*",
"Action": "s3:*",
"Resource": [
"arn:aws:s3:::S3_BUCKET_NAME",
"arn:aws:s3:::S3_BUCKET_NAME/*"
],
"Condition": {
"NotIpAddress": {
"aws:SourceIp": "LINODE_IPV4/32"
},
"StringNotEquals": {
"aws:PrincipalArn": [
"arn:aws:iam::ACCOUNT_ID:root",
"arn:aws:iam::ACCOUNT_ID:user/wordpress-media"
]
}
}
}
4. What This Does
This says:
❌ Deny access IF:
- request is NOT from your server IP
AND - request is NOT from trusted IAM identities
5. Why This Is “Safe”
You keep access via:
- ✅ Your Linode server (
LINODE_IPV4) - ✅ Your IAM user (
wordpress-media) - ✅ Your AWS root account
So even if:
- your IP changes
- you make a mistake
👉 you can still log in and fix things
6. Important Behaviour Detail
AWS evaluates this as:
| Condition | Result |
|---|---|
| From Linode IP | ✅ Allowed |
| From IAM user (any IP) | ✅ Allowed |
| From root account | ✅ Allowed |
| Anything else | ❌ Denied |
7. Optional Tightening (Later)
Once confident, you could remove:
"arn:aws:iam::ACCOUNT_ID:user/wordpress-media"
So only:
- server IP
- root account
…but that’s stricter and less flexible.
8. Where This Fits with Your Current Setup
You would have:
✔ Allow (existing)
- IAM user → S3 access (with IP restriction ideally)
✔ Allow
- CloudFront → S3 (unchanged)
✔ Deny (new safe layer)
- Blocks everything else
9. Common Mistake to Avoid
Do NOT do this:
"NotIpAddress": { ... }
without also allowing a trusted principal
👉 That’s how lockouts happen
10. Recommended Approach for You
Given your current stage:
Step 1 (Now)
✔ Use IP-restricted Allow only (simple, safe)
Step 2 (After testing)
✔ Add Safe Deny pattern
Step 3 (Later, optional)
✔ Tighten further if needed
11. Real-World Advice
Honestly, for your setup:
The IP-restricted
Allowalone already gives you ~90% of the benefit.
The safe deny is:
- more “defence-in-depth”
- useful if credentials were leaked
…but not strictly required.
12. Bottom Line
- A safe deny = deny everyone except known good identities
- Always include:
- root account
- at least one IAM user you control
- Never rely on IP-only deny without a fallback
If you want, I can:
- merge this into your AWS document as a final “hardened policy example”, or
- review your full combined policy (Allow + Deny + CloudFront) as one clean block 👍
Here is a final version:
Nice—this is exactly the right place to land things 👍
Below is a clean, final hardened S3 bucket policy that combines everything:
- ✅ CloudFront access (OAC)
- ✅ WordPress IAM user access (IP restricted)
- ✅ Safe deny (with recovery path)
All using your placeholder format.
Final Hardened S3 Bucket Policy (Clean Example)
{
"Version": "2012-10-17",
"Id": "PolicyForCDN",
"Statement": [
{
"Sid": "AllowCloudFrontServicePrincipal",
"Effect": "Allow",
"Principal": {
"Service": "cloudfront.amazonaws.com"
},
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::S3_BUCKET_NAME/*",
"Condition": {
"StringEquals": {
"AWS:SourceArn": "arn:aws:cloudfront::ACCOUNT_ID:distribution/DISTRIBUTION_ID"
}
}
},
{
"Sid": "AllowWordPressMediaManagementFromLinode",
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::ACCOUNT_ID:user/wordpress-media"
},
"Action": [
"s3:PutObject",
"s3:GetObject",
"s3:DeleteObject",
"s3:ListBucket"
],
"Resource": [
"arn:aws:s3:::S3_BUCKET_NAME",
"arn:aws:s3:::S3_BUCKET_NAME/*"
],
"Condition": {
"IpAddress": {
"aws:SourceIp": "LINODE_IPV4/32"
}
}
},
{
"Sid": "DenyAllExceptApprovedAccess",
"Effect": "Deny",
"Principal": "*",
"Action": "s3:*",
"Resource": [
"arn:aws:s3:::S3_BUCKET_NAME",
"arn:aws:s3:::S3_BUCKET_NAME/*"
],
"Condition": {
"NotIpAddress": {
"aws:SourceIp": "LINODE_IPV4/32"
},
"StringNotEquals": {
"aws:PrincipalArn": [
"arn:aws:iam::ACCOUNT_ID:root",
"arn:aws:iam::ACCOUNT_ID:user/wordpress-media"
]
}
}
}
]
}
How This Works (Quick Sanity Check)
✅ CloudFront
- Allowed via:
cloudfront.amazonaws.comSourceArn = DISTRIBUTION_ID
- Not affected by IP restriction
✅ WordPress (nginx on Linode)
- Must be:
- IAM user:
wordpress-media - AND from
LINODE_IPV4
- IAM user:
✅ You (Recovery Access)
- Root account always allowed
- IAM user allowed (even outside IP, due to safe deny logic)
❌ Everyone Else
- Explicitly denied
Important Behaviour Note (Subtle but Critical)
This condition:
"NotIpAddress" + "StringNotEquals"
acts like:
Deny only if BOTH are true
Meaning:
- If request is from trusted IAM → NOT denied
- If request is from trusted IP → NOT denied
This is what makes it “safe”.
Recommended Deployment Approach
Step 1
Apply without the Deny block
Step 2
Confirm:
- WordPress upload works
- CloudFront serves files
Step 3
Add the Deny block
Step 4
Test:
- Server → works ✅
- Local machine → denied ❌
Optional Tweaks (Later)
Tighten further (advanced):
- Remove IAM user from safe deny (forces IP-only access)
Add:
- Logging (S3 access logs or CloudTrail)
- Object-level restrictions if needed
Final Thought
This is a very solid production-grade policy for a non-EC2 setup:
- Stronger than most typical S3 + WordPress configs
- Still recoverable if something goes wrong
- Clean separation of:
- CDN access
- application access
- global protection
THE IAM USER POLICY
This cleans up what we already had:
Exactly right—you want the IAM policy to align cleanly with the bucket policy, so they reinforce each other without conflicting or creating confusion.
Below is a clean, production-aligned IAM policy that matches your hardened S3 bucket policy.
Aligned IAM Policy (wordpress-media User)
This mirrors:
- S3 access scope ✅
- IP restriction ✅
- CloudFront invalidation ✅
Final IAM Policy Example
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "WordPressMediaBucketAccessFromLinode",
"Effect": "Allow",
"Action": [
"s3:PutObject",
"s3:GetObject",
"s3:DeleteObject",
"s3:ListBucket",
"s3:GetBucketLocation"
],
"Resource": [
"arn:aws:s3:::S3_BUCKET_NAME",
"arn:aws:s3:::S3_BUCKET_NAME/*"
],
"Condition": {
"IpAddress": {
"aws:SourceIp": "LINODE_IPV4/32"
}
}
},
{
"Sid": "CloudFrontInvalidationAccess",
"Effect": "Allow",
"Action": "cloudfront:CreateInvalidation",
"Resource": "arn:aws:cloudfront::ACCOUNT_ID:distribution/DISTRIBUTION_ID"
}
]
}
How This Aligns with Your Bucket Policy
1. S3 Access
| Layer | Restriction |
|---|---|
| IAM Policy | Must be from LINODE_IPV4 |
| Bucket Policy | Must be from LINODE_IPV4 |
| Result | ✅ Double enforcement |
2. CloudFront
| Component | Control |
|---|---|
| IAM Policy | Allows invalidation only |
| Bucket Policy | Allows CloudFront read via OAC |
| Result | ✅ Clean separation |
3. Defence-in-Depth
Even if one layer is misconfigured:
- IAM policy blocks unauthorised IPs
- Bucket policy blocks unauthorised principals
- Safe deny blocks everything else
Why This Is a Good Balance
✅ Secure
- IP restriction enforced in two places
- Explicit deny protects bucket
✅ Maintainable
- Clear separation of concerns
- Easy to read and audit
✅ Recoverable
- Safe deny still allows root/IAM recovery
Important Behaviour Note
Even if someone gets your keys:
❌ They cannot use them unless they are on
LINODE_IPV4
That’s the big win here.
Optional Enhancements (Later)
If you want to tighten further over time:
1. Restrict to specific prefixes (e.g. uploads only)
"arn:aws:s3:::S3_BUCKET_NAME/wp-content/uploads/*"
2. Add User-Agent condition (lightweight extra check)
3. Rotate keys regularly
One Subtle Tip (Worth Keeping)
You’ve aligned names nicely:
[wordpress-media]
Matching:
- IAM user
- AWS profile
👉 This makes debugging and future maintenance much easier.
Final Recommendation
You now have:
- ✅ Hardened S3 bucket policy
- ✅ Matching IAM policy
- ✅ Safe deny with recovery path
- ✅ IP-restricted access
That’s a very solid, production-grade setup, especially for a non-EC2 environment.

