Strapi Media Library Setup
Strapi stores media locally in public/uploads by default. For production this is unacceptable: files are not replicated between instances, do not scale, are lost on container rebuild. Setup includes moving storage to S3-compatible cloud and configuring image breakpoints.
Storage Providers
| Provider | Package |
|---|---|
| AWS S3 | @strapi/provider-upload-aws-s3 |
| Cloudinary | @strapi/provider-upload-cloudinary |
| DigitalOcean Spaces | via aws-s3 (S3-compatible) |
| Cloudflare R2 | via aws-s3 + custom endpoint |
AWS S3 Setup
npm install @strapi/provider-upload-aws-s3
// config/plugins.js
module.exports = ({ env }) => ({
upload: {
config: {
provider: 'aws-s3',
providerOptions: {
s3Options: {
credentials: {
accessKeyId: env('AWS_ACCESS_KEY_ID'),
secretAccessKey: env('AWS_ACCESS_KEY_SECRET'),
},
region: env('AWS_REGION', 'eu-central-1'),
endpoint: env('AWS_ENDPOINT', undefined), // for S3-compatible
params: {
ACL: env('AWS_ACL', 'public-read'),
Bucket: env('AWS_BUCKET'),
},
},
},
actionOptions: {
upload: {},
uploadStream: {},
delete: {},
},
},
},
});
Cloudflare R2
R2 is S3 API compatible with free egress bandwidth:
module.exports = ({ env }) => ({
upload: {
config: {
provider: 'aws-s3',
providerOptions: {
s3Options: {
credentials: {
accessKeyId: env('R2_ACCESS_KEY_ID'),
secretAccessKey: env('R2_SECRET_ACCESS_KEY'),
},
region: 'auto',
endpoint: `https://${env('CF_ACCOUNT_ID')}.r2.cloudflarestorage.com`,
params: {
Bucket: env('R2_BUCKET'),
},
},
},
},
},
});
For public file access, you need R2 custom domain or Cloudflare Worker.
Cloudinary
Automatic optimization and transformations:
npm install @strapi/provider-upload-cloudinary
module.exports = ({ env }) => ({
upload: {
config: {
provider: 'cloudinary',
providerOptions: {
cloud_name: env('CLOUDINARY_NAME'),
api_key: env('CLOUDINARY_KEY'),
api_secret: env('CLOUDINARY_SECRET'),
},
actionOptions: {
upload: {
folder: env('CLOUDINARY_FOLDER', 'strapi'),
transformation: [{ quality: 'auto', fetch_format: 'auto' }],
},
uploadStream: {},
delete: {},
},
},
},
});
Image Formats and Breakpoints
Strapi automatically creates multiple sizes on upload:
// config/plugins.js — in upload.config section
breakpoints: {
xlarge: 1920,
large: 1000,
medium: 750,
small: 500,
xsmall: 64,
},
Formats are stored in the formats field of media object. In API response:
{
"url": "/uploads/image.jpg",
"formats": {
"thumbnail": { "url": "/uploads/thumbnail_image.jpg", "width": 156 },
"small": { "url": "/uploads/small_image.jpg", "width": 500 },
"medium": { "url": "/uploads/medium_image.jpg", "width": 750 }
}
}
File Size and Type Restrictions
// config/plugins.js
module.exports = {
upload: {
config: {
sizeLimit: 20 * 1024 * 1024, // 20 MB
// Type filtering via middleware or lifecycle hook
},
},
};
Type filtering via lifecycle:
// src/extensions/upload/strapi-server.js
module.exports = (plugin) => {
const originalUpload = plugin.controllers.upload.upload;
plugin.controllers.upload.upload = async (ctx) => {
const { files } = ctx.request;
const allowed = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf'];
const invalid = Object.values(files).flat()
.filter(f => !allowed.includes(f.type));
if (invalid.length) {
return ctx.badRequest('Unsupported file type');
}
return originalUpload(ctx);
};
return plugin;
};
Timeline
- Setting up S3/R2/Cloudinary provider — a few hours
- Migrating existing files from local storage — 1 day (depends on volume)
- Custom type filtering + restrictions — a few hours







