# S3 Uploads: moving the WordPress media library to S3 without a UI

> A Human Made plugin that rewrites upload URLs to S3 and adds WP-CLI commands for IAM policies, migration and verification. It deliberately ships no admin screen, which is both its strength and its limit.

**humanmade/S3-Uploads** — The WordPress Plugin to Store Uploads on Amazon S3

- Repository: https://github.com/humanmade/S3-Uploads
- Stars: 2,153 · Forks: 404
- Language: PHP
- License: GPL-2.0
- Published: 2026-10-06 · Updated: 2026-10-06 · Language: en
- Canonical page: https://hysenlabs.com/projects/humanmade-s3-uploads

## A drop-in with no admin screen on purpose

The README describes S3 Uploads as a lightweight drop-in for storing uploads on Amazon S3 instead of the local filesystem, and it is unusually clear about what it leaves out. It is focused on providing an S3 interface with no bells and whistles, no WP-Admin UI, and not much otherwise. What it does add is a set of WP-CLI commands for generating IAM users, listing files in the bucket and migrating an existing media library.

That restraint is the design. There is no settings page to configure, no bucket browser and no connection test button. Configuration happens through constants in wp-config.php, which means the plugin's behaviour is defined by your environment rather than by the contents of the options table in the database. That has a useful side effect for staging and production parity: the same code with the same constants produces the same behaviour.

It is a Human Made project maintained by Joe Hoyle, licensed GPL-2.0, requiring PHP 7.4 or newer and WordPress 5.3 or newer. The last push was on 2026-03-09 and the repository is not archived.

## Composer autoloading comes before everything else

Installation is through Composer rather than the WordPress plugin directory, and the ordering requirement is the part people miss. The README says Composer's autoloader must be loaded before S3 Uploads is loaded, and recommends doing it in wp-config.php before wp-settings.php runs.

```bash
composer require humanmade/s3-uploads
```

```php
require_once __DIR__ . '/vendor/autoload.php';
```

If you get that wrong, the plugin cannot find its own classes and WordPress fails early with an error that does not obviously point at autoloading. The plugin lives in a single PHP file at the root of the repository, s3-uploads.php, with the implementation in an `inc/` directory, and the repository also carries phpunit.xml.dist, a psalm.xml configuration and a codecov.yml, so it is built and tested like a library that happens to be a plugin.

The activation command differs depending on how you installed it. If you cloned the directory yourself, the name has to match the directory you cloned into. If you used Composer, the name is lowercase:

```bash
wp plugin activate s3-uploads
```

## Bucket, region and credentials as constants

The configuration block is short and lives in wp-config.php. Bucket and region are required, and credentials come either directly or from an instance profile:

```php
define( 'S3_UPLOADS_BUCKET', 'my-bucket' );
define( 'S3_UPLOADS_REGION', '' ); // the s3 bucket region (excluding the rest of the URL)
```

Region is the bare region value, excluding the rest of the URL, and the README links to the AWS region list for the exact strings. Credentials can be set inline with S3_UPLOADS_KEY and S3_UPLOADS_SECRET, or left alone when you are on an EC2 instance with an IAM role, in which case S3_UPLOADS_USE_INSTANCE_PROFILE is set to true and the instance's own credentials are used.

The bucket constant accepts an optional path prefix, which is a small detail with real consequences. Setting the bucket to my-bucket/my-folder uploads everything into that folder inside the bucket, so several sites can share one bucket without colliding. The README also points at AWS guidance on managing access keys, which matters because the inline credential method puts a long-lived secret in a file on your web host.

With the constants in place, the first thing to do is verify the setup rather than upload a whole media library:

```bash
wp s3-uploads verify
```

## Migrating an existing media library

The migration path is a single command that copies an existing uploads directory into the bucket, with a verbose flag for watching progress.

```bash
wp s3-uploads upload-directory /path/to/uploads/ uploads
```

There is also a general-purpose `cp` command for arbitrary copying, in either direction.

```bash
wp s3-uploads cp <from> <to>
```

Because either end can be S3 or a local path, the README is explicit that a full S3 location has to be spelled out with the s3:// scheme, as in `cp ./test.txt s3://mybucket/test.txt`. For listing what is actually in the bucket, there is a dedicated command with an optional path:

```bash
wp s3-uploads ls [<path>]
```

The README describes that one as being for debugging, which is honest about its purpose. If you are trying to work out why an image 404s, listing the bucket is the fastest way to see whether the object is there under the path you expect.

You also need permissions before any of this works. The plugin can generate an IAM policy document for you with `wp s3-uploads generate-iam-policy`, and the README is clear that you create the user yourself or attach the permissions to an existing one. It will not create AWS resources for you.

## Private attachments need a filter, not a checkbox

WordPress treats uploaded media as public by default, and so does this plugin. For sites where that is wrong, S3 Uploads can set the private ACL on S3 objects and hand out temporary signed URLs instead. The interesting part is how you decide which attachments are private: the plugin makes no assumptions and provides no UI, so you supply the `s3_uploads_is_attachment_private` filter. To mark everything private, one line does it:

```php
add_filter( 's3_uploads_is_attachment_private', '__return_true' );
```

Individual attachments can be flipped either way by calling `set_attachment_files_acl` with a target ACL, for example public-read. Signed URLs default to a six-hour expiry, and the `s3_uploads_private_attachment_url_expiry` filter changes it, accepting any string that `strtotime` understands, such as a one hour offset.

For audit trails there is a separate companion plugin, S3 Uploads Audit, which logs actions such as ACL changes. It lives in its own repository rather than this one, so treat it as an optional add-on rather than part of the feature set.

Cache headers are the other half of serving media well, and both are configurable by constant. S3_UPLOADS_HTTP_CACHE_CONTROL sets the default Cache-Control value, for example an expression that expires in 30 days, and S3_UPLOADS_HTTP_EXPIRES sets the Expires header, which can be pushed years into the future for assets you never want to expire.

## Controlling when uploads start going to S3

Activating the plugin immediately rewrites image URLs to S3 and sends new uploads there. The README explains why that is sometimes the wrong order: a site owner may want to push a large existing media library across with the WP-CLI commands first, and only then have live upload requests go to S3.

Setting S3_UPLOADS_AUTOENABLE to false in wp-config.php delays that switch, so the bucket gets populated by an explicit migration and the plugin starts serving from S3 afterwards. This is worth understanding before activating on a site with thousands of attachments, because the ordering is not something you can undo by deactivating and reactivating.

The release history suggests a project that keeps up with the platform underneath it. Version 3.0.13, published on 2026-02-25, moved the PHP requirement to 7.4, corrected an error message, added the GPL-2.0+ LICENSE file and upgraded the test infrastructure to PHPUnit 9.6 and PHP 8.3 with a fix for modern Minio compatibility in the test setup. Version 3.0.11 in October 2025 switched listing to ListObjectsV2 where applicable, replaced `sys_get_temp_dir` with `get_temp_dir`, and added a `get_s3_path` function instead of hard coding the S3 path. 3.0.12 added a backport workflow.

The PHP 8 readdir fix in 3.0.11 is the kind of entry worth noting. Plugins that walk directories often break on a PHP 8 behaviour change, and this one was patched rather than left to rot.

Compared with a plugin that adds a bucket picker to the media library, this one asks you to write configuration into a PHP file and run commands. That is more work up front and considerably less to operate later, and it is a good trade when the person deploying is comfortable in wp-config.php but should never have to think about storage again.

## Conclusion

S3 Uploads fits a team that already runs WordPress through WP-CLI and wants media on S3 without handing credentials to a plugin repository or maintaining an admin screen. It is a poor fit if you want per-attachment private files decided from the editor, a CDN layer, or an upload progress bar, since the README states the plugin offers no bells and whistles and no WP-Admin UI, and it asks you to supply a filter rather than a checkbox for private ACLs. Configure the constants in wp-config.php, load Composer's autoloader before wp-settings.php, run wp s3-uploads generate-iam-policy and wp s3-uploads verify before uploading anything, and decide about S3_UPLOADS_AUTOENABLE before activating.

## FAQ

### How do I install the S3 Uploads WordPress plugin?

Through Composer, with `composer require humanmade/s3-uploads`, and Composer's autoloader must be loaded in wp-config.php before wp-settings.php. Then activate it with `wp plugin activate s3-uploads`, using the lowercase name when the plugin came from Composer.

### How do I migrate existing WordPress uploads to S3?

Use the upload-directory command, for example `wp s3-uploads upload-directory /path/to/uploads/ uploads`. The README also documents a general `cp` command that copies in either direction, and `ls` for listing what is already in the bucket.

### How do I make WordPress uploads private on S3?

The plugin provides no UI for it. You supply the `s3_uploads_is_attachment_private` WordPress filter, which the README suggests returning true from to mark every attachment private. Private files then get temporary signed URLs, six hours by default, adjustable with the `s3_uploads_private_attachment_url_expiry` filter.

### Which AWS credentials does S3 Uploads use?

You can set them directly with the S3_UPLOADS_KEY and S3_UPLOADS_SECRET constants, or leave them unset and set S3_UPLOADS_USE_INSTANCE_PROFILE to true so the EC2 instance role credentials are used instead. The plugin can generate an IAM policy with `wp s3-uploads generate-iam-policy`, but creating the user is left to you.

### Does S3 Uploads have a settings page in the WordPress admin?

No, and that is deliberate. The README describes it as a lightweight drop-in with no WP-Admin UI, configured entirely through constants in wp-config.php such as S3_UPLOADS_BUCKET, S3_UPLOADS_REGION and the cache header constants.

## Sources

- [humanmade/S3-Uploads on GitHub](https://github.com/humanmade/S3-Uploads)
- [Issues](https://github.com/humanmade/S3-Uploads/issues)
- [License: GPL-2.0](https://github.com/humanmade/S3-Uploads/blob/master/LICENSE)
- [README](https://github.com/humanmade/S3-Uploads/blob/master/README.md)
- [Releases](https://github.com/humanmade/S3-Uploads/releases)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/humanmade-s3-uploads
