Skills DirectorySkills Directory
SkillsLearnSecurityCategoriesDocsCommunityBlog
Sign InSubmit Skill
Skills Directory

Security-tested agent skills for Claude, coding agents, and AI workflows.

Directory

  • Browse Skills
  • All Skills A–Z
  • Claude Skills
  • Claude Code Skills
  • Agent Skills
  • Categories
  • Submit a Skill

Learn

  • Learn Hub
  • Install Claude Skills
  • Write SKILL.md
  • Skills vs MCP
  • Directories Compared

Security

  • Security
  • Methodology
  • Secure Claude Skills
  • Security Badges

Company

  • About
  • Community
  • Blog
  • API Docs
  • Advertise

2026 Skills Directory. All rights reserved.

Back to skills

Wp S3

ASecurity

Move a WordPress site's media to S3 with the S3 Uploads plugin — installs the plugin with its vendor tree, writes s3-config.php and the endpoint mu-plugin, migrates the existing library with a transfer that verifies itself, and reverses the whole thing. Use when the user wants uploads on S3, object storage, MinIO or any S3-compatible server, mentions S3 Uploads, offloading media, a media CDN on CloudFront, or asks to sync wp-content/uploads with a bucket. Run through /wp-s3 and /wp-s3-media.

6 stars
0 votes
0 copies
0 views
Added 9/20/2026
developmentpythongophpshellbashexpressawsgitapidatabase

Works with

cliapi

Security Analysis

A100/100

Scanned 9/20/2026

Install to Claude Code

$npx -y skills add yojahny55/claude-wp-builder --skill wp-s3 --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Wp S3?

Add the live security badge to your README — it updates automatically with every re-scan.

Security grade badge for Wp S3
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/yojahny55-wp-s3/badge)](https://www.skillsdirectory.com/skills/yojahny55-wp-s3)

More formats (shields.io, HTML) on the badges page.

Download Zip
Files
SKILL.md
---
name: wp-s3
description: Move a WordPress site's media to S3 with the S3 Uploads plugin — installs the plugin with its vendor tree, writes s3-config.php and the endpoint mu-plugin, migrates the existing library with a transfer that verifies itself, and reverses the whole thing. Use when the user wants uploads on S3, object storage, MinIO or any S3-compatible server, mentions S3 Uploads, offloading media, a media CDN on CloudFront, or asks to sync wp-content/uploads with a bucket. Run through /wp-s3 and /wp-s3-media.
user-invocable: false
---

# wp-s3: WordPress media on S3

## What this skill does

1. **Installs** S3 Uploads (Human Made) with its `vendor/` tree, verified against a pinned
   commit, and leaves it deactivated
2. **Writes** `s3-config.php` at the site root and hooks it into `wp-config.php`
3. **Installs** an mu-plugin that points the plugin at an S3-compatible endpoint, and does
   nothing on AWS
4. **Migrates** `wp-content/uploads` to the bucket, and **verifies** that every byte arrived
5. **Brings the media back** and unhooks the configuration when the site leaves S3

It does not activate the plugin and does not enable rewriting. Both are one WP-CLI command
away and belong to whoever owns the maintenance window.

## Why this plugin

S3 Uploads registers `s3://` as a PHP stream wrapper, so WordPress writes to the bucket
through the ordinary filesystem calls. That has three consequences worth knowing before
choosing it:

- It **does not use the REST API**, so it works on sites that block `/wp-json/`.
- Media URLs are **not stored in the database**: they are built from
  `S3_UPLOADS_BUCKET_URL` at render time. Changing the media domain is changing one
  constant, not a search-and-replace over `wp_posts`.
- It supports a **custom endpoint**, so the same configuration runs against AWS and against
  any S3-compatible server.

## Requirements

| Requirement | Why |
|---|---|
| PHP ≥ 8.1 with `simplexml`, `json`, `pcre`, `curl`, `mbstring` | The AWS SDK the plugin bundles |
| `allow_url_fopen = On` | The plugin registers `s3://` as a URL-type stream wrapper |
| `composer` | Releases since 3.0.10 ship without `vendor/`. On a PHP with no `iconv` the install adds `--ignore-platform-req=ext-iconv` by itself |
| `git` | The plugin is cloned at a pinned commit and refused if the tag no longer points there. Without git the install stops: `--unverified-download` takes the tarball instead, which carries nothing that can be checked |
| `curl`, `tar`, `python3` | The setup script; `curl` and `tar` only on the `--unverified-download` path |
| A client binary named `mcli` or `mc` | Only for `/wp-s3-media` |
| WP-CLI | Optional. The credentials are proved by `scripts/check-credentials.php`, which does not need the plugin to be active |

## How to use

Configure a site:

```bash
S3_UPLOADS_SECRET_VALUE='<secret>' bash <path-to-skill>/scripts/s3-setup.sh \
    --wp-root /path/to/wordpress \
    --bucket '<bucket>' --region '<region>' \
    --bucket-url 'https://media.example.com' \
    --auth key --key '<access-key-id>'
```

On a server with an IAM role, drop `--key` and the secret and pass `--auth instance`. For an
S3-compatible server, add `--endpoint 'https://s3.example.com'`.

**The secret travels in the environment, never in a flag.** Anything in `argv` is readable
by every user on the machine through `ps`, and lands in the shell history of whoever pasted
it.

Migrate the library, and check first:

```bash
bash <path-to-skill>/scripts/s3-media.sh upload /path/to/wordpress --dry-run
bash <path-to-skill>/scripts/s3-media.sh upload /path/to/wordpress
```

Then, and only then:

```bash
cd /path/to/wordpress && wp plugin activate S3-Uploads && wp s3-uploads enable
```

Reverse everything:

```bash
bash <path-to-skill>/scripts/s3-revert.sh /path/to/wordpress
```

## What the configuration sets, and why

| Constant | Value | Reason |
|---|---|---|
| `S3_UPLOADS_AUTOENABLE` | `false` | Activating the plugin must not move a file. Rewriting starts at `wp s3-uploads enable`, when someone is watching |
| `S3_UPLOADS_OBJECT_ACL` | `bucket-owner-full-control` | The plugin sends an ACL on every upload. AWS has had ACLs disabled by default since 2023, and a public ACL fails the upload outright |
| `S3_UPLOADS_HTTP_CACHE_CONTROL` | `2592000` | 30 days. An edited image is written under a new name, so no URL ever changes meaning |
| `WC_LOG_DIR` | local path | WooCommerce logs are not media. In the bucket they cost storage and are one bad policy line away from being public |
| `WPCF7_UPLOADS_TMP_DIR` | local path | A form attachment that round-trips to S3 is slower and needlessly exposed |

`s3-config.php` is written `0640` and added to the site's `.gitignore`. Give it the web
server's group. `wp-config.php` is backed up as `wp-config.php.bak-<timestamp>` before the
`require` is inserted, and the insertion is idempotent.

## The transfer verifies itself

`mcli mirror` can exit `0` without having transferred everything. It was measured **writing
7 of 38 objects with no warning**, and printing its summary table while the storage backend
was down. The summary is printed on failure too, so neither it nor the exit code is evidence
that the media moved.

`scripts/lib-mirror.sh` therefore checks the result instead of the report:
`scripts/verify-transfer.py` lists both sides and compares names and sizes. An upload must
account for every local file; a download for every object. Extra files on the other side are
not a failure — that is what an environment nobody migrated in this run looks like.

Neither direction ever passes `--overwrite` or `--remove`. A file already on the other side
is refused and reported, which is what makes a second run safe and what stops a stale local
copy from burying a newer one in the bucket. The client exits non-zero for those refusals,
so the exit code cannot separate "declined to clobber" from "could not connect": a refusal
is counted and reported, any other `<ERROR>` line fails the run, and the comparison decides
whether the result is right either way.

**The comparison runs even when the transfer failed**, and especially then: a failure is
the moment the operator most needs to know how much of it landed. It cannot rescue the run
— a failed transfer stays failed whatever the comparison says — but "47 of 812 objects
arrived" is what makes the next run safe, and the client's own error says nothing about
that. The listing has a timeout of 300 seconds (`WP_S3_LIST_TIMEOUT`), because an
unreachable endpoint otherwise hangs with nothing to read.

Excluded from both directions: `wc-logs/*`, `cache/*`, `wio_backup/*`, `wrio/*`,
`wpcf7_uploads/*` — logs, caches, the image optimizer's untouched originals, and the form
attachments. None is ever served from a media URL. The last two are also the directories a
mistake in the bucket policy would expose, and a site migrating in arrives with years of
them: keeping them out of the bucket is the guard that does not depend on the policy being
right.

The client is pointed at a **private configuration directory** (`MC_CONFIG_DIR`, `0700`,
removed when the script exits) rather than at `MC_HOST_<alias>`. The credentials in that
URL are not percent-decoded by the client — measured — so a secret holding `/`, `@`, `+`,
`%`, `#` or `?` works only verbatim, and one holding `:` cannot be expressed in it at all.
AWS secret keys are base64, so `/` and `+` are ordinary. `mcli alias set` is not used
either: it takes the secret in `argv`, where `ps` shows it to every user on the machine.

**On a server with an IAM role there is no key pair to hand the client.** `/wp-s3-media`
stops and offers two ways out: temporary credentials in the environment for that one
transfer, or `wp s3-uploads upload-directory`, which uses the role but reports no summary,
so the result has to be counted by hand.

## After the migration

| Plugin | What it needs |
|---|---|
| WooCommerce, **downloadable products** | Settings → Products → Approved download directories: add `<bucket-url>/uploads/`. Set the download method to **Redirect**. Without the first, saving a product fails with "not in an approved directory"; without the second, the customer's download 404s |
| Elementor | `wp elementor replace-urls '<site>/wp-content/uploads' '<bucket-url>/uploads'` then `wp elementor flush-css`. Elementor stores absolute URLs inside its own data and regenerates its CSS straight into the bucket afterwards |
| Robin Image Optimizer | Nothing. It optimizes and writes `.webp` into the bucket; its backups stay private |
| Polylang | Nothing |
| Contact Form 7 | Covered by `WPCF7_UPLOADS_TMP_DIR`. Still send one test submission with an attachment |
| Anything else that writes into `uploads` | Check it one at a time on staging. If its files must be public, its path has to be added to the bucket policy |

## Staging checklist

Run all of it on staging before production. The first row is the one that fails.

| # | Check | Expected |
|---|---|---|
| 1 | Upload an image from Media → Add new | No error. `AccessControlListNotSupported` or `AccessDenied` means the ACL question below |
| 2 | Its URL | Starts with the bucket URL |
| 3 | `ls wp-content/uploads/<year>/<month>/` | The file is not on disk |
| 4 | List the bucket prefix | Original plus every thumbnail |
| 5 | Open the URL in a private window | It loads |
| 6 | `curl -I <bucket-url>/uploads/wc-logs/` | `403` |
| 7 | Change a product image; view the product and its gallery | Served from the bucket |
| 8 | Rotate an image and save | New files with an `-e<timestamp>` suffix |
| 9 | Delete an image permanently | Original and thumbnails gone from the bucket |
| 10 | Edit and save an Elementor page, console open | Saves, no CORS error |
| 11 | Submit a Contact Form 7 form with an attachment | The mail arrives with the attachment |
| 12 | Force a WooCommerce log entry | It lands in `wc-logs-local/`, not in the bucket |
| 13 | Buy a downloadable product | The download works |
| 14 | Take S3 away from the site | The site still answers; images do not load; uploads fail with a visible admin error |

## Known failures

**`AccessControlListNotSupported` on the first upload.** The bucket has ACLs disabled and
the plugin sent one. `bucket-owner-full-control` is what the configuration ships and AWS
accepts it. If it still fails, set the bucket's Object Ownership to *bucket owner preferred*
and `S3_UPLOADS_OBJECT_ACL` to `private`, keeping Block Public Access on and serving through
CloudFront.

**Paid downloadable products have no clean answer with this plugin.** With the *Redirect*
method the customer receives the file's public URL, and anyone holding that URL can fetch
it. Kept private under `woocommerce_uploads/`, the redirect returns `403`. Either keep those
files off S3, or sign CloudFront URLs — custom work. Confirm whether the site sells
downloadables **before** configuring anything.

**`composer install` refuses the lock file: `ext-iconv` is missing.** Several
distributions ship a PHP without it. The dependency that declares it — Symfony's mbstring
polyfill, reached only through their console — never runs inside WordPress, so
`scripts/s3-setup.sh` adds `--ignore-platform-req=ext-iconv` by itself when `php -m` does
not list iconv. If a composer run failed before that existed, the plugin directory is
there without `vendor/`: run the setup again and it completes the install rather than
treating the directory as an installed plugin.

**`'s3-uploads' is not a registered wp command.`** That subcommand only exists while the
plugin is active, and setup leaves it deactivated on purpose. It is not what proves the
credentials — `scripts/check-credentials.php` does, through the SDK the plugin bundles,
with the plugin off. After activating, `wp s3-uploads verify` works as usual.

**A missing image after the migration, `403` in the network panel.** A path that is not in
the bucket policy. Add it there; do not make the bucket public.

**The site is slow and image-less.** The object storage is unreachable. The site does not go
down — pages answered in 1.8–7.0s against a stopped backend, `wp-admin` still redirected,
no fatals — but media-heavy pages took roughly five times longer. On AWS, CloudFront's cache
absorbs this, which is why its TTLs are long.

## AWS

`references/aws.md` holds the infrastructure side: bucket settings, lifecycle rule,
CloudFront with OAC, the bucket policy that keeps private paths private, the IAM policy,
networking, and what to hand over to whoever configures WordPress.

## Reverting

`scripts/s3-revert.sh` disables rewriting, **downloads the media before removing the
configuration** — without `s3-config.php` there is no bucket, region or credential left to
fetch them with — deactivates the plugin, removes the `require`, and renames `s3-config.php`
and the mu-plugin to `.disabled` rather than deleting them. It deletes nothing in the
bucket. Two things it cannot undo, because they are site data: Elementor's stored URLs and
WooCommerce's download settings. It prints both commands, filled in.

Attribution

yojahny55yojahny55
View sourceMore from yojahny55 →
SSkills DirectorySkills Directory

Know which skills are safe — weekly.

Best new skills + every skill we flagged as malicious. From the team that scanned 103,619.

Join free

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments (0)

No comments yet. Be the first to comment!

SSkills DirectorySkills Directory

Know which skills are safe — weekly.

Best new skills + every skill we flagged as malicious. From the team that scanned 103,619.

Join free

Related Skills

Browser Extension Developer

Use this skill when developing or maintaining browser extension code in the `browser/` directory, including Chrome/Firefox/Edge compatibility, content scripts, background scripts, or i18n updates.

281612 votes

Seo Optimizer

SEO optimization with keyword analysis, readability assessment, technical validation, content quality. Use for search rankings, blog posts, content audits, or encountering keyword density, readability scores, meta tags, schema markup errors.

2132 votes

Google Official Seo Guide

Official Google SEO guide covering search optimization, best practices, Search Console, crawling, indexing, and improving website search visibility based on official Google documentation

1862 votes

Tanstack Start

Build a full-stack TanStack Start app on Cloudflare Workers from scratch — SSR, file-based routing, server functions, D1+Drizzle, better-auth, Tailwind v4+shadcn/ui. Use whenever the user mentions TanStack Start, asks to scaffold a full-stack Cloudflare app with SSR, wants an SSR dashboard, or asks for a React 19 + Cloudflare Workers app with file-based routing and server functions — even if they don't name TanStack Start specifically. No template repo — Claude generates every file fresh per ...

9881 votes

Pentest

PTES-aligned adversarial security audit for backend, frontend, and mobile applications. Produces a CVSS-scored Hacker Report with verified PoCs and phased remediation.

5491 votes
View all in development →