Skip to content

Uploads and Object Storage

web/ is generated and web/wp/ comes from Composer, so a deploy can throw the whole document root away and rebuild it. web/wp-content/uploads/ is the exception: it is the only directory holding something neither the repository nor Composer can reproduce.

On a VPS with a persistent disk that does not matter. On anything that replaces the container on release — a PaaS, Kubernetes, a scale-to-zero host — every image uploaded since the last deploy goes with it.

Føhn does not implement the offload. humanmade/s3-uploads does, and has done for 2.5 million installs; what Føhn adds is the configuration, from .env like everything else about a deploy.

Setup

bash
composer require humanmade/s3-uploads
wp plugin activate s3-uploads

Then in .env:

dotenv
S3_UPLOADS_BUCKET=my-project-media
S3_UPLOADS_REGION=eu-west-3
S3_UPLOADS_BUCKET_URL=https://media.example.com
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=

The generated wp-config.php defines the S3_UPLOADS_* constants the plugin reads only when S3_UPLOADS_BUCKET is set. Leave it out and nothing is defined at all — uploads stay on local disk whether or not the plugin is installed, which is what makes a development environment work with the same wp-config.php as production.

Nothing is written to .env for you. Credentials come from the environment, and on a platform that supplies real environment variables you need no .env at all.

Instance profiles

Leave AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY out and neither constant is defined, which is what lets the AWS SDK fall back to an IAM instance profile. Add S3_UPLOADS_USE_INSTANCE_PROFILE to config/wordpress.config.php to make that explicit.

Where the images are served from

Two modes, and the choice decides what hostname ends up in your HTML.

From the bucket or a CDN. Set S3_UPLOADS_BUCKET_URL and it appears in the page — <img src>, every srcset candidate, every wp_get_attachment_url(). Point it at the CDN and the bucket never takes front-end traffic. Leave it out and the plugin derives an AWS bucket URL, which is right for AWS and wrong for everything else.

From your own domain. Set S3_UPLOADS_DISABLE_REPLACE_UPLOAD_URL=true instead and the plugin stops rewriting URLs: WordPress keeps emitting /wp-content/uploads/… and a webserver or CDN rule maps that prefix to the bucket. The plugin's README names this setup and it is why the constant exists.

dotenv
S3_UPLOADS_DISABLE_REPLACE_UPLOAD_URL=true
nginx
location ^~ /wp-content/uploads/ {
    proxy_pass http://your-bucket-endpoint/your-bucket/uploads/;
    proxy_set_header Authorization "";
    expires 30d;
}

The second mode costs a proxy hop and buys one origin. Every media URL is your own domain, so nothing breaks when the bucket is renamed, moved behind a CDN, or reached on a different port — and there is no second hostname whose TLS certificate has to be right before images appear. packages/demo uses it; see .ddev/nginx/uploads-proxy.conf.

Set both and S3_UPLOADS_DISABLE_REPLACE_UPLOAD_URL wins — the plugin checks it first and never consults the bucket URL for serving.

Anything that is not AWS

R2, Scaleway, DigitalOcean Spaces, Ceph and MinIO all speak the S3 API at an endpoint of their own, and the plugin's constants have no way to say so — its documentation tells you to write the filter yourself, in an mu-plugin. Føhn ships that filter as an opt-in hook class:

php
<?php
// app/foehn.config.php

use Studiometa\Foehn\Config\FoehnConfig;
use Studiometa\Foehn\Hooks\S3UploadsEndpoint;

return new FoehnConfig(hooks: [S3UploadsEndpoint::class]);
dotenv
S3_UPLOADS_ENDPOINT=https://s3.fr-par.scw.cloud
S3_UPLOADS_PATH_STYLE=false
S3_UPLOADS_CHECKSUMS=true
VariableWhat it decides
S3_UPLOADS_ENDPOINTThe API endpoint. Nothing else here applies without it, and without it the plugin talks to AWS.
S3_UPLOADS_PATH_STYLEBucket in the path (endpoint/bucket/key) rather than in the hostname. MinIO and Ceph want true; R2 and Scaleway do not.
S3_UPLOADS_CHECKSUMSSet false against a provider that rejects the integrity headers AWS SDK 3.337 made the default.

Getting S3_UPLOADS_CHECKSUMS wrong shows up as uploads failing with an error that never mentions checksums, so it is worth trying first when a provider that should work does not.

Verifying

bash
wp s3-uploads verify

That checks the credentials and a round trip. It does not check that S3_UPLOADS_BUCKET_URL serves what was written, and that is the failure that costs an afternoon: the media library looks perfect and every image on the site 404s. Upload something and request its URL:

bash
curl -I "$(wp eval 'echo wp_get_attachment_url(ID);')"

A 403 on a bucket that accepted the write usually means the bucket policy denies anonymous reads — which is also what a proxy hits, since it fetches anonymously. Object ACLs are not enough on their own on most S3-compatible providers — see packages/demo/tests/smoke/provision-bucket.php for the policy the demo applies to MinIO.

Migrating an existing library

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

Both come from the plugin. Do not delete the local copies until a request for a public URL returns the bytes.

What this does not do

It is a stream wrapper, so wp-content/uploads stops being a real directory. Code that writes into it with fopen() and expects fseek() to behave keeps working, over the network, slowly. Code that builds a path by hand from WP_CONTENT_DIR does not.

Every intermediate sub-size write is a network round trip, #[AsImageSize] sizes included. Correct, and slower per upload than writing them locally.

It is a plugin, so someone with admin access can deactivate it and put uploads back on local disk without touching a deploy.

Private files are out of scope. Everything served is public. A media library with access control is a different feature with a different threat model.

Seeing it work

packages/demo runs the whole path against MinIO in a ddev service: a real upload, the original and every sub-size in the bucket, nothing left on local disk, and a request to the public URL that returns the bytes. packages/demo/tests/smoke/run.sh is the shortest description of what "working" means here.

Released under the MIT License.