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

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_basedir restrictions triggered

9. Common Issues & Gotchas

9.1 Permissions Problems

  • Incorrect .aws permissions → 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
  • .aws permissions are critical
  • www.conf must 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_IPV4 are 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:

  1. Add IP restriction without breaking anything
  2. Verify it works
  3. 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:

  1. Go to AWS Console → S3 → Bucket Policy
  2. Remove the Condition block
  3. 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 Deny in a bucket policy overrides everything—even admin permissions


Example of a Problem Scenario

If you apply this:

“Effect”: “Deny”,
“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

  • Allow with IP restriction
  • No explicit Deny

⚠️ Risky

  • Adding global Deny with NotIpAddress

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:

  1. Keep a known-good policy copy locally
  2. Apply changes in AWS Console (not CLI)
  3. Keep the browser tab open until confirmed working
  4. Test immediately from:
    • server (should work)
    • local machine (should fail)

Simple Rule of Thumb

If you’re not 100% sure about a Deny statement—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 Allow alone 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.com
    • SourceArn = DISTRIBUTION_ID
  • Not affected by IP restriction

✅ WordPress (nginx on Linode)

  • Must be:
    • IAM user: wordpress-media
    • AND from LINODE_IPV4

✅ 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.


 

Disclaimer: This content is provided as reference only and reflects practical experience at the time of writing. Technology and best practices change, so examples may require modification. No warranty is provided. Always test configurations on a development system before using them in production.