Configuring Strapi Uploads with S3 and a CDN
This guide belongs to Strapi Self-Hosted Setup and moves Strapi’s media library from the local disk to Amazon S3, served through a CDN. It covers the upload provider configuration, a private bucket that only the CDN can read, the admin panel’s content security policy, responsive image formats, cache headers, and moving existing files without breaking the URLs stored in content.
By default Strapi writes uploads to public/uploads on the server’s disk. That works for a single development machine and fails everywhere else: containers lose files on restart, several instances each see a different set of files, and backups of the database no longer match backups of the media. An object store fixes all three, and a CDN in front of it serves files quickly and cheaply to readers everywhere, without every image request hitting Strapi.
The Problem
A publisher ran Strapi in two containers behind a load balancer with uploads on local disk. Images uploaded through one container returned 404 when the other container served the request, so editors saw images appear and disappear depending on which instance they hit. After a redeployment, the containers started with empty upload folders and every image uploaded since the previous deployment was gone from the site, although the media library still listed them. Restoring them took a day of matching database entries with files from an old backup.
How the S3 Provider Works
The provider replaces local storage. @strapi/provider-upload-aws-s3 uploads each file and each generated format to the bucket and stores the resulting URL in the database. The media library works as before; only the storage changes.
baseUrl makes stored URLs point at the CDN. Without it, stored URLs point directly at the bucket. With baseUrl set to the CDN domain, every URL in content is a CDN URL, and the bucket can stay private.
Credentials come from the environment. On AWS, the AWS SDK picks up the task or instance role automatically; no access keys are needed in the configuration. Elsewhere, pass keys from a secret store.
The admin panel must be allowed to load the files. Strapi’s security middleware sends a content security policy for the admin panel. Add the CDN and bucket domains to img-src and media-src, or thumbnails in the media library appear broken.
Implementation
Install the provider and configure it in config/plugins.ts. The configuration below uses the task role for credentials and the CDN for URLs, and sets responsive breakpoints and a size limit.
// config/plugins.ts
export default ({ env }) => ({
upload: {
config: {
provider: "aws-s3",
providerOptions: {
baseUrl: env("CDN_URL"), // e.g. https://media.example.com
rootPath: env("UPLOAD_ROOT", "uploads"),
s3Options: {
region: env("AWS_REGION"),
params: {
Bucket: env("AWS_BUCKET"),
// The bucket enforces owner-owned objects with ACLs disabled; send no public ACL.
ACL: env("AWS_ACL", "private"),
},
},
},
sizeLimit: 50 * 1024 * 1024, // 50 MB, in bytes
breakpoints: { xlarge: 1920, large: 1280, medium: 768, small: 480 },
},
},
});
Allow the admin panel to display files from the CDN and the bucket:
// config/middlewares.ts
export default ({ env }) => [
"strapi::logger",
"strapi::errors",
{
name: "strapi::security",
config: {
contentSecurityPolicy: {
useDefaults: true,
directives: {
"img-src": ["'self'", "data:", "blob:", "market-assets.strapi.io", new URL(env("CDN_URL")).host],
"media-src": ["'self'", "data:", "blob:", new URL(env("CDN_URL")).host],
upgradeInsecureRequests: null,
},
},
},
},
"strapi::cors",
"strapi::poweredBy",
"strapi::query",
"strapi::body",
"strapi::session",
"strapi::favicon",
"strapi::public",
];
The task role needs only object access to the upload prefix, with no ACL permissions:
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": ["s3:PutObject", "s3:GetObject", "s3:DeleteObject"],
"Resource": "arn:aws:s3:::media-example-com/uploads/*"
}]
}
On the CDN side, create a distribution with the bucket as origin and origin access control, and a bucket policy that allows reads only from that distribution. Block all public access on the bucket.
Cache headers
Strapi gives each upload a unique file name with a hash, so a file’s URL never points to different content. That allows long cache lifetimes: set Cache-Control: public, max-age=31536000, immutable as a response header policy on the CDN for the upload path. Replacing a file in the media library keeps its URL, which is the one exception; purge that path on the CDN when it happens, or ask editors to upload a new file instead of replacing one.
Migrating existing uploads
Moving an existing installation from local disk to S3 has two parts: copying the files, and rewriting URLs stored in the database. Copy public/uploads to the bucket under the same root path with aws s3 sync. Then update the url and each format’s url in the files table from /uploads/... to the CDN base URL. Rich text and Markdown fields may also contain upload URLs; search them for /uploads/ and rewrite them in the same migration. Run it on a copy of the database first, compare a sample of pages before and after, and keep the local files until the new URLs have served traffic for a while.
Configuration Reference
| Setting | Recommendation | Why |
|---|---|---|
| Provider | @strapi/provider-upload-aws-s3 |
Maintained, supports formats and deletes. |
baseUrl |
CDN domain | Stored URLs never expose the bucket. |
| Credentials | task or instance role | No keys in configuration. |
| Bucket | private, public access blocked | Only the CDN reads it. |
| CSP | CDN host in img-src, media-src |
Media library previews load. |
| Cache | one year, immutable |
Hashed file names never change. |
sizeLimit |
set explicitly in bytes | Large uploads fail clearly. |
Gotchas & Edge Cases
- Proxy body limits. A reverse proxy or load balancer with a small body limit rejects large uploads before Strapi sees them; align it with
sizeLimit, and set the body middleware’s limit too. - ACLs on buckets without ACLs. Buckets with object ownership set to bucket owner enforced reject requests that set a public ACL. Do not send
public-read. - Private files. A private bucket behind a public CDN is still public for anyone with the URL. For truly private documents, use a separate bucket and signed URLs.
- Image formats and cost. Every breakpoint creates another file. Keep breakpoints to the sizes the frontend actually requests.
- Local development. Use the local provider in development, or a local S3-compatible store, selected by environment, so developers do not write to the production bucket.
Worked Example
The publisher moved its media library to a private S3 bucket behind CloudFront with the S3 provider, the task role and the CSP changes, and migrated 41,000 files and their stored URLs over one weekend. Images stopped disappearing between instances, redeployments no longer touched media, and the containers’ disk usage fell to almost nothing. Readers’ image load times improved as well, because files were served from edge locations with year-long cache headers instead of from the application servers.
Serving Images to the Frontend
With media on a CDN, the frontend can request exactly the sizes it needs. Use the generated formats in srcset for responsive images, reading their URLs and widths from the media object instead of guessing sizes. When the frontend uses an image optimization service, such as Next.js image optimization, add the CDN domain to its allowed remote patterns and let it resize from the original; in that case, fewer Strapi breakpoints are needed. Always render the alternative text stored with the media entry, and require it in the content model for images that carry meaning, so accessibility does not depend on editors remembering it. Width and height from the media object prevent layout shifts while images load.
Rollout Checklist
- Install and configure the S3 provider with
baseUrlset to the CDN. - Use a role for credentials and grant only object access to the upload prefix.
- Keep the bucket private and let the CDN read it through origin access.
- Add the CDN host to the admin panel’s
img-srcandmedia-src. - Set year-long cache headers and purge only replaced files.
- Migrate existing files and rewrite stored URLs, including rich text.
Frequently Asked Questions
Can I use another S3-compatible store?
Yes. Stores such as Cloudflare R2 or MinIO work with the same provider through the endpoint option in s3Options, with their own CDN or public domain as baseUrl.
Does deleting a file in Strapi delete it from S3?
Yes, the provider deletes the original and its formats. CDN caches may keep serving it until they expire, so purge the path when removal is urgent.
What about videos?
They upload the same way, but large videos are better served by a video platform with adaptive streaming, with Strapi storing only a reference.
Do I need Strapi’s own public/uploads folder anymore?
No. Once all files are in the bucket and the URLs are migrated, the folder can be removed from containers and backups. Keep a final archive of it for a few weeks, until you are sure nothing still references the old paths.