To send Strapi's media uploads to an S3-compatible store that is not Amazon, install @strapi/provider-upload-aws-s3, set provider: 'aws-s3' in config/plugins.js, and put four things under providerOptions.s3Options: the credentials, a region name, the store's endpoint, and forcePathStyle: true. Add params.Bucket for the bucket and baseUrl for the address browsers will load files from. That is the whole configuration. Nearly every failure afterwards comes from one of three places: the addressing style (the SDK builds a hostname that does not exist), the public URL (files upload but do not display), or the access control list sent with each upload (the store refuses it). This post sets the provider up properly, explains each key, and then works through the errors in the order you are likely to meet them.
It assumes Strapi is already running. For the surrounding production decisions - secrets, database, admin build, upgrades - see Strapi in production, which covers the same provider in one section. This post is the long version of that section.
What the upload provider does#
Strapi's Upload plugin is the code behind the Media Library and the /api/upload endpoint. The plugin does not store files itself. It hands each file to a provider, a small package with three jobs: upload a buffer, upload a stream, and delete. The default provider writes to public/uploads on the server's disk. The S3 provider writes to a bucket instead.
The path a file takes matters for troubleshooting, so it is worth having in your head:
- The editor drops a file into the Media Library. The browser sends it to Strapi, not to the bucket.
- Strapi receives the multipart request, reads the file, and for images generates the extra sizes (
thumbnail_,small_,medium_andlarge_variants, depending on your breakpoints). - For each file and each variant, the provider makes one S3
PutObjectrequest from the Strapi process to the endpoint. - Strapi stores the resulting public URL in its database, in the
urlfield of the file record. - When a page or the admin shows the image, the browser loads that URL directly from the storage. Strapi is not in the path.
Two consequences follow. First, uploads need no CORS configuration on the bucket, because the browser never writes to it; CORS only matters if you later add direct browser uploads with presigned URLs (see S3 presigned URLs). Second, the server that runs Strapi needs to reach the storage endpoint, while your visitors need to reach whatever baseUrl points at. Those can be two different addresses, and keeping them straight is most of the configuration.
What you need from the storage#
Before touching Strapi, collect five values from your S3-compatible store:
| Value | Example | Used for |
|---|---|---|
| Endpoint URL | https://s3.example.com | endpoint |
| Access key and secret | AKIA... and a long string | credentials |
| Bucket name | strapi-media | params.Bucket |
| Region name | us-east-1 | region |
| Public base URL | https://s3.example.com/strapi-media | baseUrl |
The region is the one people stall on. Amazon uses it to pick a data centre and to build the request signature. A store that is not Amazon has no region to pick, but the SDK still insists on a value because the signature includes one. The rule is to use whatever the store says, and if it says nothing, any well-formed name such as us-east-1 works on most of them. What matters is that the same string is used everywhere the store is accessed. S3 path-style vs virtual-hosted URLs, and regions explained goes through why.
Test the values with something other than Strapi first. If the AWS CLI cannot list the bucket, Strapi will not manage it either, and the CLI gives a much clearer error. Tell the CLI to use path-style addressing, then list:
$ aws configure set default.s3.addressing_style path$ export AWS_ACCESS_KEY_ID=...$ export AWS_SECRET_ACCESS_KEY=...$ aws --endpoint-url https://s3.example.com --region us-east-1 s3 ls s3://strapi-media/An empty bucket prints nothing and exits cleanly, which is a pass. A wrong key prints InvalidAccessKeyId or SignatureDoesNotMatch; a wrong bucket prints NoSuchBucket. S3 storage with the AWS CLI has the full set of commands if you want to go further.
Install the provider#
From the Strapi project folder:
$ npm install @strapi/provider-upload-aws-s3Despite the name, this is the provider to use for any S3-compatible service. There is no separate "generic S3" package from Strapi. Community providers exist for specific vendors, but for an endpoint you control the official one is the better choice, because it is the one that tracks Strapi releases. Match its major version to your Strapi major version; the package is published with the same versioning as Strapi itself, and a provider from an older major is a common cause of confusing errors after an upgrade. Check the installed version with npm ls @strapi/provider-upload-aws-s3 and compare it to the @strapi/strapi version in package.json.
The configuration, key by key#
Create or edit config/plugins.js (or config/plugins.ts in a TypeScript project, which has the same structure with export default):
module.exports = ({ env }) => ({ upload: { config: { provider: 'aws-s3', providerOptions: { baseUrl: env('S3_PUBLIC_URL'), rootPath: env('S3_ROOT_PATH', ''), s3Options: { credentials: { accessKeyId: env('S3_ACCESS_KEY_ID'), secretAccessKey: env('S3_SECRET_ACCESS_KEY'), }, region: env('S3_REGION', 'us-east-1'), endpoint: env('S3_ENDPOINT'), forcePathStyle: true, params: { Bucket: env('S3_BUCKET'), }, }, }, actionOptions: { upload: {}, uploadStream: {}, delete: {}, }, }, },});The names inside env() are yours to choose; the structure around them is fixed. Here is what each key does:
| Key | What it does |
|---|---|
provider | Must be 'aws-s3', the short name of the package |
providerOptions.baseUrl | The address stored in the database and used by browsers |
providerOptions.rootPath | A prefix, so several apps can share a bucket |
s3Options.credentials | Access key and secret, as accessKeyId and secretAccessKey |
s3Options.region | Required by the SDK even when the store ignores it |
s3Options.endpoint | The store's URL; without it the SDK talks to Amazon |
s3Options.forcePathStyle | Bucket in the path, not in the hostname |
s3Options.params.Bucket | The bucket every object goes into |
actionOptions | Extra parameters passed to each upload, uploadStream or delete call |
Everything under s3Options is passed to the AWS SDK for JavaScript v3 S3Client constructor, which is why the names match the SDK's own documentation. That is useful to know: any option the SDK client accepts can go there, including the checksum options covered below.
rootPath deserves a word. Without it, objects land at the bucket's root with a generated key like thumbnail_cat_4f2a1b.jpg. With rootPath: 'cms', the key becomes cms/thumbnail_cat_4f2a1b.jpg. It is worth setting if the bucket is shared with anything else, and it costs nothing to set on day one. Changing it later does not move existing objects, and the stored URLs keep pointing at the old keys.
The environment variables#
Keep the credentials out of the config file and in .env in the project folder, next to the Strapi secrets:
S3_ENDPOINT=https://s3.example.comS3_REGION=us-east-1S3_BUCKET=strapi-mediaS3_ACCESS_KEY_ID=...S3_SECRET_ACCESS_KEY=...S3_PUBLIC_URL=https://s3.example.com/strapi-mediaS3_ROOT_PATH=Never commit this file. Strapi reads .env on start, so after editing it restart the server; no admin rebuild is needed for these values, because they are used by the server process and not by the browser bundle. Environment variables and secrets covers the habits that keep a .env out of a repository. If you host Strapi on RE:NODE, the file manager and SFTP both reach the project folder, so the file can be edited in the browser - see SFTP and the file manager.
One detail that catches people: an empty value such as S3_ROOT_PATH= is an empty string, and env('S3_ROOT_PATH', '') returns it as such. That is fine here. But an empty S3_ENDPOINT= is also an empty string, which the SDK treats as no endpoint, and it quietly sends your requests to Amazon. If uploads fail with an InvalidAccessKeyId message from a store that has never heard of your key, check that the variable is actually set and spelled the way the config reads it.
Why path-style, and what happens without it#
Given an endpoint and a bucket, the AWS SDK builds one of two URLs. Virtual-hosted style, the default, puts the bucket in the hostname: https://strapi-media.s3.example.com/cat.jpg. Path-style puts it in the path: https://s3.example.com/strapi-media/cat.jpg. Virtual-hosted needs a DNS record for every bucket name, and a certificate that covers them. A store that sits behind one hostname has neither, so the SDK's default produces a name that does not resolve.
The failure is distinctive. If forcePathStyle is missing or false, the Strapi log shows something like this on the first upload:
error: getaddrinfo ENOTFOUND strapi-media.s3.example.comLook at the hostname in the message. If it starts with your bucket name, the SDK is using virtual-hosted style, and forcePathStyle: true in s3Options is the fix. If the hostname is plain s3.example.com and the error is a different one, addressing is correct and the problem is elsewhere. This one check removes half of the guessing.
There is a subtler version. A store that has wildcard DNS and a wildcard certificate may accept virtual-hosted requests, and uploads will work without the setting. If you later move to a store that does not, the same configuration breaks. Set forcePathStyle: true regardless; path-style works on every S3-compatible store, including Amazon's, which still accepts it for existing buckets.
baseUrl: why files upload but do not show#
The provider writes objects through endpoint, but the URL it stores in the database is built from baseUrl. The two are separate on purpose, since the address you write to is often not the address you want the world to read from. A CDN, a proxy with a nicer hostname, or a public path on the same host are all common.
If baseUrl is not set, the provider builds the URL from the SDK's idea of the bucket location. With a custom endpoint and path-style addressing that is usually the endpoint plus the bucket, which can be correct, but it can also be an address that only the Strapi server can reach, such as an internal or plain-HTTP one. Setting baseUrl explicitly removes the guesswork.
For path-style addressing the base URL has the bucket in it: https://s3.example.com/strapi-media. The stored URL is then the base plus the key, so a file stored as thumbnail_cat_4f2a1b.jpg is served from https://s3.example.com/strapi-media/thumbnail_cat_4f2a1b.jpg. Open that address in a private browser window, with no credentials, and see whether the image loads. That one test separates the three common failures:
- The browser warns about mixed content, or blocks the image. The admin panel is on HTTPS and the stored URL is plain
http://. Browsers block HTTP images on an HTTPS page. Use an HTTPS name inbaseUrl. - The URL returns `403` or `AccessDenied`. The object exists but anonymous reads are not allowed. See the next section.
- The URL returns `404` or `NoSuchKey`. The URL does not match the key. Check
rootPathand whether the bucket name is inbaseUrl.
The Media Library has a fourth, Strapi-specific way to fail: the files load in a new tab, but the thumbnails in the admin panel are broken. That is Strapi's own Content Security Policy refusing images from a host it was not told about, and the fix lives in config/middlewares.js. The Strapi production guide has the exact strapi::security block with img-src and media-src; add the host of your baseUrl to both.
Public files, private files and ACLs#
By default the provider uploads every object with a public-read ACL, which is what lets baseUrl links work for anonymous visitors. That is the right behaviour for a marketing site's images. It is the wrong behaviour for anything that should not be public, and it can also be rejected by the store.
An ACL is a per-object permission header sent along with PutObject. Amazon has been moving away from them: new Amazon buckets block ACLs by default and rely on bucket policies. Some S3-compatible stores never implemented ACLs at all, and answer an upload that carries one with AccessControlListNotSupported or a generic NotImplemented. If your uploads fail with an ACL complaint and the credentials are fine, that is the cause. The provider reads the ACL from params, so you control it there:
params: { ACL: 'private', signedUrlExpires: 15 * 60, Bucket: env('S3_BUCKET'),},With ACL: 'private', objects are not readable anonymously and the provider switches to signed URLs: when Strapi returns a file in an API response, it signs a time-limited URL and returns that instead of the plain one. signedUrlExpires is the lifetime in seconds. Two practical notes:
- Signed URLs expire, so a page that caches an API response for longer than
signedUrlExpireswill show broken images. Match the cache lifetime to the signature lifetime, or keep the bucket public for assets that belong on public pages. - The signature covers the hostname. The signed URL is built from the
endpoint, so that endpoint has to be one the visitor's browser can reach. An internal address that works for the Strapi server will sign URLs that nobody else can open. S3 presigned URLs explains why.
Whether a given store can make a bucket or a path readable without ACLs is a property of that store, not of Strapi: some use bucket policies, some have a "public" switch per bucket, some have neither. Check what yours supports, and test anonymous access with curl -I on an uploaded file before you tell editors it is ready. The same applies to a store that rejects ACLs: if you cannot send a public-read ACL and cannot set a policy, signed URLs with ACL: 'private' are the working route. Check the provider's README for the exact handling of a null or omitted ACL in your installed version, because that behaviour has changed between releases.
Upload checksums with newer SDK versions#
The provider is built on the AWS SDK for JavaScript v3. Since early 2025 the SDK adds a CRC-based integrity checksum to uploads by default. Some S3-compatible servers do not accept those extra headers, and the failure looks odd: the Media Library lists objects fine, deletes fine, and every upload fails with SignatureDoesNotMatch, MissingContentLength or a complaint about checksums or x-amz-content-sha256.
The SDK client has two options that turn the new behaviour down to "only when the operation requires it". Because s3Options is passed straight to the client, they go there:
s3Options: { credentials: { /* ... */ }, region: env('S3_REGION', 'us-east-1'), endpoint: env('S3_ENDPOINT'), forcePathStyle: true, requestChecksumCalculation: 'WHEN_REQUIRED', responseChecksumValidation: 'WHEN_REQUIRED', params: { Bucket: env('S3_BUCKET') },},Only add these if you see that symptom: they exist in SDK releases from early 2025 onward, and an older SDK ignores unknown options. If your installed provider pulls an older SDK, upgrading the provider is the cleaner fix. The same issue appears with the command line client and with other SDKs, and S3 from Node.js and Python shows the equivalent settings there. A useful tell: if aws s3 cp works against the endpoint and Strapi does not, compare the CLI and SDK versions before anything else.
Testing, and checking what actually happened#
With the config in place, restart Strapi and upload one image through the Media Library. Do these checks in order:
- The server log. Start Strapi in a terminal, or read the panel console. Any provider error is logged at the moment of the upload, with the real message. Do not trust the admin's generic "Internal Server Error" toast.
- The bucket. List it with the CLI. You should see the original and, for images, the
thumbnail_,small_,medium_andlarge_variants, each as a separate object. Useaws --endpoint-url https://s3.example.com s3 ls s3://strapi-media/as before. - The URL. Click the file in the Media Library, copy its link, and open it in a private window.
- The delete. Delete the test file in the admin and list again. The provider's
deletecall removes the original and every variant; if objects remain, the credentials probably lack permission to delete, which is easy to miss when a key was created with write-only access.
Step 4 is the one people skip. A key that can write but not delete leaves orphans in the bucket that nobody sees until the bill, or the quota, shows them.
Moving the files you already have#
Changing provider does not move anything. Files uploaded earlier still have /uploads/... URLs in the database and still live in public/uploads. There are three honest options:
- Leave them. Keep
public/uploadson the server's disk and let new files go to the bucket. Old links keep working as long as the folder is there. It is the lowest-risk choice, and it is fine for a small library. - Copy and rewrite. Sync
public/uploadsinto the bucket with a tool such asrcloneor the AWS CLI (see rclone with S3 storage), then update theurl(and the per-format URLs in theformatsJSON column) in thefilestable to the new base. This is a database change, so take a Strapi backup first and test it on a copy. Query the real column names from your own schema rather than copying them from a tutorial. - Export and import. Strapi's
strapi exportandstrapi importcommands move content and, with the right flags, media. They are the supported route for moving between environments, but check that your version's import writes through the configured provider before relying on it for a migration.
Whichever you choose, do it in a quiet hour and keep a backup of both the database and public/uploads until you have looked at the result in the admin. A migration that is only tested by uploading a new image has not tested the old ones.
Keeping the bucket and the database in step#
Once uploads live in a bucket, the database and the bucket become two things that must agree. Backing up one without the other gives you either records with no files or files with no records.
- A Strapi backup (a database copy, or
strapi export) holds the references. It does not hold the images. - A copy of the bucket holds the images. It does not know which are used.
- Restore both from the same day, or accept that some records will point at missing files.
The bucket has its own durability to think about. If the store keeps one copy in one place, it is not a backup of anything; it is the primary copy, and it needs its own backup. Read the 3-2-1 rule for S3 storage for how to size that, and restic backups to S3 for a way to take it.
Strapi with an S3 plan on RE:NODE#
On RE:NODE, Strapi is a one-click app on the app hosting page, installed and built on the server's first start with data in SQLite on the server. An S3 object storage plan is a separate server that provides what this post needs: an S3-compatible endpoint, an access key and secret key generated for that server, and a first bucket already created. It uses path-style addressing and accepts any region name, so forcePathStyle: true and a region such as us-east-1 are the right values. The endpoint answers plain HTTP on its own port, and HTTPS goes through the plan's proxy slot: point a hostname at the address shown and the certificate is issued and renewed for you. Use that HTTPS name in endpoint and baseUrl, or the admin will block the images as mixed content.
The S3 plans do not include backup slots, and the storage is one copy on NVMe in one location, not replicated. That is fine for media you can re-create, and a reason to copy anything irreplaceable somewhere else. The Strapi server's own backup slots cover the project folder and SQLite file, not the bucket. Whether anonymous reads work on the bucket and how to enable them is not something this post can promise for you; test an uploaded file in a private window as described above.
Troubleshooting#
| Symptom | Likely cause |
|---|---|
ENOTFOUND bucket.host | forcePathStyle missing or in the wrong place |
InvalidAccessKeyId from your own store | Endpoint empty, so the request went to Amazon |
NoSuchBucket | Wrong params.Bucket, or the bucket is not created |
AccessControlListNotSupported | The store rejects ACLs; set ACL: 'private' |
SignatureDoesNotMatch on upload only | New default checksums; see the checksum section |
SignatureDoesNotMatch everywhere | Wrong secret, or a proxy rewrote the Host header |
| Uploads work, thumbnails broken | img-src in config/middlewares.js |
| Images blocked in the admin | baseUrl is http:// on an HTTPS admin |
413 on upload | Reverse proxy body limit, not Strapi or the store |
403 on the public URL | Object not publicly readable |
A few notes that do not fit in a table. SignatureDoesNotMatch on every request means the credentials are wrong or the signature is being broken in transit; the usual in-transit culprit is a reverse proxy in front of the store that does not forward the original Host header, since the signature covers it. If it only happens on uploads, think about checksums before credentials.
For a 413, the limit is a setting of whatever proxy sits in front of Strapi. Uploads from the admin go through it. Raise the body size there, and consider Strapi's own sizeLimit under the upload plugin's config so editors get a clear message instead of a dropped connection.
If Strapi fails to start after the change, with a message that the provider cannot be loaded, the package is not installed in the folder Strapi runs from: run npm install there, not on your laptop, and restart. And remember that a change to config/plugins.js needs a restart, but changes to the admin's Content Security Policy in config/middlewares.js need a restart too; neither needs a rebuild.
FAQ#
Do I need CORS configured on the bucket for Strapi uploads?
No. Uploads go from the browser to Strapi and from Strapi to the bucket, so the browser never writes to the bucket and CORS does not apply. You need CORS only if you add direct browser uploads with presigned URLs, or load fonts or canvas data from the bucket.
Which region name should I use for a non-Amazon store?
Use the one the store documents. If it documents none, use us-east-1. The SDK needs some value to sign requests with, and a store that is not Amazon accepts whatever it was told to expect. Keep the same string in Strapi, the CLI and every other client.
Can I use the S3 provider with a bucket that is not public?
Yes. Set ACL: 'private' under s3Options.params and the provider returns signed URLs that expire after signedUrlExpires seconds. Cache API responses for less time than that, and make sure the signing endpoint is reachable from the visitor's browser.
Why do uploads work but the Media Library thumbnails stay blank?
Strapi's Content Security Policy only allows images from its own origin and a short list of hosts. Add the host of your baseUrl to img-src and media-src in config/middlewares.js, and restart.
Will switching to S3 move my existing media?
No. Existing files keep their /uploads/... URLs and stay on the server's disk. Copy them to the bucket and update the stored URLs, or leave them where they are and let only new uploads use the bucket.
Does Strapi delete the files from the bucket when I delete them in the admin?
Yes, through the provider's delete call, which removes the original and its resized variants. The access key needs permission to delete objects, and a key limited to writing will leave the files behind.


