A comprehensive guide to integrating AWS S3 storage with Strapi v5, including private bucket configuration, presigned URLs, and migrating existing media files.
Table of Contents
- Overview
- Prerequisites
- Phase 1: Install Dependencies
- Phase 2: Configure AWS S3
- Phase 3: Create Presigned URL Service
- Phase 4: Auto-Attach Presigned URLs
- Phase 5: Migrate Existing Files
- Phase 6: Fix AWS SDK Compatibility
- Troubleshooting
- Security Considerations
Overview
This integration provides:
- ✅ Private S3 bucket storage - All files stored securely
- ✅ Presigned URLs - Temporary, secure access to files
- ✅ Automatic URL generation - Middleware handles everything
- ✅ No frontend changes - URLs work transparently
- ✅ Organized storage - Files stored in
/uploadsfolder - ✅ Safe migration - Existing files moved to S3 without data loss
Prerequisites
Before starting, ensure you have:
- Strapi v5.x running
- AWS account with S3 access
- IAM user with S3 permissions
- AWS Access Key ID and Secret Access Key
Phase 1: Install Dependencies
Install the required AWS packages with compatible versions:
npm install @strapi/provider-upload-aws-s3
npm install @aws-sdk/client-s3@3.621.0 @aws-sdk/s3-request-presigner@3.621.0
Why these packages?
-
@strapi/provider-upload-aws-s3- Official Strapi plugin that handles S3 uploads through Strapi's upload API -
@aws-sdk/client-s3- AWS SDK v3 for S3 operations (upload, delete, check existence) -
@aws-sdk/s3-request-presigner- Generates temporary signed URLs for secure file access
Why version 3.621.0? - Newer versions have middleware stack incompatibility issues with Strapi v5's S3 provider. This version is tested and stable.
Phase 2: Configure AWS S3
Step 2.1: AWS S3 Bucket Setup
-
Create S3 Bucket (if not already created)
- Go to AWS S3 Console
- Create a new bucket in your desired region
- Why? - You need a storage location for your files. Region choice affects latency and costs.
-
Enable ACLs
- Go to bucket → Permissions → Object Ownership
- Select "ACLs enabled"
- Choose "Bucket owner preferred"
- Save changes
-
Why? - Allows setting
ACL: 'private'on individual files. Required for Strapi's S3 provider to control file access permissions.
-
Configure Bucket Policy (Optional but recommended)
- Go to Permissions → Bucket policy
- Click Edit
- Add the following policy (replace placeholders):
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowStrapiUserAccess",
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::YOUR_ACCOUNT_ID:user/YOUR_IAM_USER"
},
"Action": [
"s3:PutObject",
"s3:GetObject",
"s3:DeleteObject",
"s3:ListBucket"
],
"Resource": [
"arn:aws:s3:::YOUR_BUCKET_NAME/*",
"arn:aws:s3:::YOUR_BUCKET_NAME"
]
},
{
"Sid": "DenyAllOtherAccess",
"Effect": "Deny",
"NotPrincipal": {
"AWS": "arn:aws:iam::YOUR_ACCOUNT_ID:user/YOUR_IAM_USER"
},
"Action": "s3:*",
"Resource": [
"arn:aws:s3:::YOUR_BUCKET_NAME/*",
"arn:aws:s3:::YOUR_BUCKET_NAME"
],
"Condition": {
"StringNotEquals": {
"aws:PrincipalArn": "arn:aws:iam::YOUR_ACCOUNT_ID:user/YOUR_IAM_USER"
}
}
}
]
}
How to find your values:
- YOUR_ACCOUNT_ID: AWS Console → Top right → Click your account name → Copy the 12-digit Account ID
-
YOUR_IAM_USER: IAM → Users → Find your user → Copy the username (e.g.,
strapi-s3-user) -
YOUR_BUCKET_NAME: The name you gave your S3 bucket (e.g.,
my-strapi-uploads)
Example with real values:
"Principal": {
"AWS": "arn:aws:iam::123456789012:user/strapi-s3-user"
}
- Click Save changes
Why? - Extra security layer ensuring only your Strapi application can access the bucket, not other AWS users or services.
Step 2.2: Configure Strapi Upload Provider
Create or update config/plugins.js:
module.exports = ({ env }) => ({
upload: {
config: {
provider: 'aws-s3', // Tells Strapi to use S3 instead of local storage
providerOptions: {
credentials: {
accessKeyId: env('AWS_ACCESS_KEY_ID'), // AWS authentication
secretAccessKey: env('AWS_ACCESS_SECRET'),
},
region: env('AWS_REGION'), // S3 bucket location
params: {
ACL: 'private', // Makes files private by default (not publicly accessible)
Bucket: env('AWS_BUCKET'), // Target bucket name
},
baseUrl: env('CDN_URL', ''), // Optional: CloudFront URL if using CDN
rootPath: env('AWS_S3_PATH', 'uploads'), // Subfolder in bucket (organizes files)
},
actionOptions: {
upload: {},
uploadStream: {},
delete: {},
},
},
},
});
Why this configuration?
-
provider: 'aws-s3'- Switches from local file storage to S3 -
ACL: 'private'- Files can't be accessed without presigned URLs (security) -
rootPath: 'uploads'- All files go in/uploadsfolder for organization -
env()function - Keeps sensitive credentials out of code
Step 2.3: Environment Variables
Add to .env:
AWS_ACCESS_KEY_ID=your_access_key_id
AWS_ACCESS_SECRET=your_secret_access_key
AWS_REGION=us-east-1
AWS_BUCKET=your-bucket-name
AWS_S3_PATH=uploads
Security Note: Never commit .env to version control!
Phase 3: Create Presigned URL Service
Step 3.1: Create Folder Structure
mkdir -p src/api/upload/services
mkdir -p src/api/upload/controllers
mkdir -p src/api/upload/routes
Why these folders?
-
services/- Business logic for generating presigned URLs -
controllers/- HTTP request handlers (optional, for manual endpoints) -
routes/- API route definitions (optional, for manual endpoints)
Note: Strapi v5 doesn't auto-create these folders, you must create them manually.
Step 3.2: Presigned URL Service
Create src/api/upload/services/s3-presigned.js:
const { S3Client, GetObjectCommand } = require('@aws-sdk/client-s3');
const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');
module.exports = ({ strapi }) => ({
async getPresignedUrl(fileKey, expiresIn = 3600) {
// Load AWS credentials from environment
const accessKeyId = process.env.AWS_ACCESS_KEY_ID;
const secretAccessKey = process.env.AWS_ACCESS_SECRET;
const region = process.env.AWS_REGION;
const bucket = process.env.AWS_BUCKET;
const s3Path = process.env.AWS_S3_PATH || 'uploads';
// Validate credentials exist (fail fast if misconfigured)
if (!accessKeyId || !secretAccessKey) {
throw new Error('AWS credentials are missing. Check your .env file.');
}
if (!region || !bucket) {
throw new Error('AWS region or bucket is missing. Check your .env file.');
}
// Create S3 client with credentials
const client = new S3Client({
region: region,
credentials: {
accessKeyId: accessKeyId,
secretAccessKey: secretAccessKey,
},
});
// Construct full S3 key (path + filename)
// Example: "uploads/photo_abc123.jpg"
const fullKey = `${s3Path}/${fileKey}`;
// Create command to get the object
const command = new GetObjectCommand({
Bucket: bucket,
Key: fullKey,
});
try {
// Generate presigned URL that expires after 1 hour (3600 seconds)
// This creates a temporary URL with AWS signature
const url = await getSignedUrl(client, command, { expiresIn });
return url;
} catch (error) {
console.error('❌ Error generating presigned URL:', error.message);
throw error;
}
},
async getMultiplePresignedUrls(fileKeys, expiresIn = 3600) {
// Batch generate URLs for multiple files
const promises = fileKeys.map(key => this.getPresignedUrl(key, expiresIn));
return Promise.all(promises);
},
});
Why this service?
- Generates temporary URLs that expire (security best practice)
- Centralizes URL generation logic (reusable across app)
- Validates credentials before attempting S3 operations
- Handles the
/uploadspath prefix automatically
Step 3.3: Controller (Optional - Manual Endpoints)
Create src/api/upload/controllers/presigned.js:
module.exports = {
async getPresignedUrl(ctx) {
const { fileId } = ctx.params;
// Fetch file metadata from database
const file = await strapi.plugins.upload.services.upload.findOne(fileId);
if (!file) return ctx.notFound('File not found');
// Generate presigned URL using our service
const url = await strapi
.service('api::upload.s3-presigned')
.getPresignedUrl(file.hash + file.ext);
ctx.body = { url, expiresIn: 3600 };
},
async getMultiplePresignedUrls(ctx) {
const { fileIds } = ctx.request.body;
// Validate input
if (!Array.isArray(fileIds) || !fileIds.length) {
return ctx.badRequest('fileIds must be a non-empty array');
}
// Batch fetch files from database
const files = await strapi.db
.query('plugin::upload.file')
.findMany({ where: { id: { $in: fileIds } } });
// Generate presigned URLs for all files
const keys = files.map(f => f.hash + f.ext);
const urls = await strapi
.service('api::upload.s3-presigned')
.getMultiplePresignedUrls(keys);
ctx.body = {
files: files.map((f, i) => ({
id: f.id,
name: f.name,
url: urls[i],
})),
expiresIn: 3600,
};
},
};
Why this controller? (OPTIONAL)
- Provides manual API endpoints if you need to fetch presigned URLs on-demand
- Useful for admin panels or special cases
- Not required if you're using the middleware (which auto-generates URLs)
Step 3.4: Routes (Optional)
Create src/api/upload/routes/presigned.js:
module.exports = {
routes: [
{
method: 'GET',
path: '/upload/presigned/:fileId',
handler: 'presigned.getPresignedUrl',
config: { auth: false }, // Change to true for authentication
},
{
method: 'POST',
path: '/upload/presigned/multiple',
handler: 'presigned.getMultiplePresignedUrls',
config: { auth: false },
},
],
};
Why these routes? (OPTIONAL)
- Exposes the controller as HTTP endpoints
-
/upload/presigned/:fileId- Get single file URL -
/upload/presigned/multiple- Batch get multiple URLs - Not required if using middleware for automatic URL generation
Phase 4: Auto-Attach Presigned URLs
Step 4.1: Create Middleware
Create src/middlewares/add-presigned-urls.js:
module.exports = (config, { strapi }) => async (ctx, next) => {
await next(); // Execute request first
// Only process successful responses with content
if (ctx.status !== 200 || !ctx.body) return;
const attach = async obj => {
// Handle null/undefined
if (!obj || typeof obj !== 'object') return;
// Recursively process arrays
if (Array.isArray(obj)) return Promise.all(obj.map(attach));
// Check all values in the object
for (const value of Object.values(obj)) {
// Identify S3 media fields by checking for required properties
if (value?.hash && value?.ext && value?.provider === 'aws-s3') {
try {
// Generate fresh presigned URL
const presignedUrl = await strapi
.service('api::upload.s3-presigned')
.getPresignedUrl(value.hash + value.ext);
// Replace the static S3 URL with presigned URL
value.url = presignedUrl;
} catch (error) {
console.error(`Failed to generate presigned URL for ${value.name}:`, error.message);
// Fallback: construct basic S3 URL (won't work with private bucket, but prevents crashes)
const s3Path = process.env.AWS_S3_PATH || 'uploads';
value.url = `https://${process.env.AWS_BUCKET}.s3.${process.env.AWS_REGION}.amazonaws.com/${s3Path}/${value.hash}${value.ext}`;
}
}
// Recursively check nested objects (for relations, components, etc.)
await attach(value);
}
};
// Start recursive processing from response body
await attach(ctx.body);
};
Why this middleware? (CRITICAL - KEEP PERMANENTLY)
- Automatically replaces S3 URLs with presigned URLs on every API response
- Works with nested objects and arrays (handles relations, components, dynamic zones)
- Only processes S3 files (checks
provider === 'aws-s3') - Generates fresh URLs on each request (so they never expire for users)
- Without this: Users would get 403 Forbidden errors (private bucket)
Step 4.2: Register Middleware
Update config/middlewares.js:
module.exports = [
'strapi::logger',
'strapi::errors',
'strapi::security',
'strapi::cors',
'strapi::query',
'strapi::body',
'strapi::session',
'strapi::public',
'global::add-presigned-urls', // Add this line - runs on every response
];
Why register the middleware?
- Makes Strapi execute our middleware on every HTTP response
-
global::prefix means it's a custom middleware fromsrc/middlewares/ - Order matters: runs after Strapi's built-in middlewares
- Must keep this permanently - removing it breaks image access
Important: The middleware must remain permanently - it generates fresh presigned URLs on every request.
Phase 5: Migrate Existing Files
Step 5.1: Create Migration Script
Create scripts/migrate-media-to-s3-v5.js:
'use strict';
const fs = require('fs');
const path = require('path');
const { S3Client, PutObjectCommand, HeadObjectCommand } = require('@aws-sdk/client-s3');
module.exports = async ({ strapi }) => {
const s3 = new S3Client({
region: process.env.AWS_REGION,
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_ACCESS_SECRET,
},
});
const BUCKET = process.env.AWS_BUCKET;
const PREFIX = process.env.AWS_S3_PATH ? `${process.env.AWS_S3_PATH}/` : 'uploads/';
const files = await strapi.db.query('plugin::upload.file').findMany({
where: {
provider: { $ne: 'aws-s3' },
},
});
strapi.log.info(`Found ${files.length} files to migrate`);
let migrated = 0;
let skipped = 0;
let errors = 0;
for (const file of files) {
const filename = `${file.hash}${file.ext}`;
const localPath = path.join(strapi.dirs.static.public, 'uploads', filename);
const s3Key = `${PREFIX}${filename}`;
if (!fs.existsSync(localPath)) {
strapi.log.warn(`Missing local file: ${filename}`);
errors++;
continue;
}
let exists = false;
try {
await s3.send(new HeadObjectCommand({ Bucket: BUCKET, Key: s3Key }));
exists = true;
skipped++;
strapi.log.info(`Already exists in S3: ${filename}`);
} catch (_) {}
try {
if (!exists) {
await s3.send(
new PutObjectCommand({
Bucket: BUCKET,
Key: s3Key,
Body: fs.createReadStream(localPath),
ContentType: file.mime,
ACL: 'private',
})
);
strapi.log.info(`Uploaded to S3: ${filename}`);
}
await strapi.db.query('plugin::upload.file').update({
where: { id: file.id },
data: {
provider: 'aws-s3',
url: `https://${BUCKET}.s3.${process.env.AWS_REGION}.amazonaws.com/${s3Key}`,
provider_metadata: {
bucket: BUCKET,
key: s3Key,
region: process.env.AWS_REGION,
},
},
});
migrated++;
strapi.log.info(`✅ Migrated: ${filename}`);
} catch (err) {
errors++;
strapi.log.error(`❌ Failed: ${filename}`, err.message);
}
}
strapi.log.info('');
strapi.log.info('=============================');
strapi.log.info('🎉 Migration Summary:');
strapi.log.info(`✅ Migrated: ${migrated}`);
strapi.log.info(`⏭️ Skipped (already in S3): ${skipped}`);
strapi.log.info(`❌ Errors: ${errors}`);
strapi.log.info(`📊 Total: ${files.length}`);
strapi.log.info('=============================');
strapi.log.info('');
};
Step 5.2: Create Runner Script
Create scripts/run-s3-migration.js:
'use strict';
const Strapi = require('@strapi/strapi');
(async () => {
const app = await Strapi().load();
const migrate = require('./migrate-media-to-s3-v5');
await migrate({ strapi: app });
await app.destroy();
process.exit(0);
})();
Step 5.3: Run Migration
node scripts/run-s3-migration.js
What this does:
- Finds all files in database where
provider !== 'aws-s3'(local files only) - Checks if each file exists in
public/uploads/ - Uploads file to S3 at
uploads/filename_hash.ext - Updates database: sets
provider: 'aws-s3'and proper S3 URL - Skips files already in S3 (safe to run multiple times)
- Logs detailed progress and summary
The script is idempotent - safe to run multiple times. Already migrated files are automatically skipped.
Step 5.4: Fix URL Issues (If Needed)
If you encounter invalid URL errors, create scripts/fix-s3-urls.js:
'use strict';
const Strapi = require('@strapi/strapi');
(async () => {
const app = await Strapi().load();
const BUCKET = process.env.AWS_BUCKET;
const REGION = process.env.AWS_REGION;
const PREFIX = process.env.AWS_S3_PATH ? `${process.env.AWS_S3_PATH}/` : 'uploads/';
const files = await app.db.query('plugin::upload.file').findMany({
where: { provider: 'aws-s3' },
});
app.log.info(`Found ${files.length} S3 files to check`);
let fixed = 0;
let skipped = 0;
for (const file of files) {
const s3Key = `${PREFIX}${file.hash}${file.ext}`;
const properUrl = `https://${BUCKET}.s3.${REGION}.amazonaws.com/${s3Key}`;
// Check if URL is null, "null" string, or doesn't match S3 format
if (!file.url || file.url === 'null' || !file.url.includes('s3.amazonaws.com')) {
await app.db.query('plugin::upload.file').update({
where: { id: file.id },
data: {
url: properUrl,
provider_metadata: {
bucket: BUCKET,
key: s3Key,
region: REGION,
},
},
});
app.log.info(`✅ Fixed: ${file.name}`);
fixed++;
} else {
skipped++;
}
}
app.log.info('');
app.log.info('=============================');
app.log.info('🎉 URL Fix Summary:');
app.log.info(`✅ Fixed: ${fixed}`);
app.log.info(`⏭️ Already OK: ${skipped}`);
app.log.info('=============================');
await app.destroy();
process.exit(0);
})();
When to use this script:
- If migration set
url: nulland Strapi throws "Invalid URL" errors - If some database entries have malformed URLs
- Safe to run anytime - only fixes broken URLs, leaves good ones alone
Run if needed:
node scripts/fix-s3-urls.js
Phase 6: Fix AWS SDK Compatibility
Issue
Strapi v5's S3 provider has compatibility issues with newer AWS SDK versions, causing middleware stack errors:
Error: serializerMiddleware is not found when adding endpointV2Middleware
Why this happens:
- Strapi's S3 provider uses older AWS SDK v3 internal APIs
- Newer SDK versions (3.700.0+) changed middleware stack architecture
- Version mismatch causes initialization failures
Solution
Downgrade to compatible versions:
# Remove incompatible versions
npm uninstall @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
# Install tested, compatible versions
npm install @aws-sdk/client-s3@3.621.0 @aws-sdk/s3-request-presigner@3.621.0
Why version 3.621.0?
- Last stable version before middleware stack changes
- Fully compatible with Strapi v5's S3 provider
- Tested and verified working
Or add to package.json:
{
"overrides": {
"@aws-sdk/client-s3": "3.621.0",
"@aws-sdk/s3-request-presigner": "3.621.0"
}
}
Why use overrides?
- Forces all dependencies to use this version
- Prevents npm from upgrading to incompatible versions
- Ensures consistency across the dependency tree
Then clean install:
rm -rf node_modules package-lock.json
npm install
Why clean install?
- Removes all cached dependencies
- Rebuilds entire dependency tree
- Ensures no version conflicts remain
Troubleshooting
Error: "Could not load credentials from any providers"
Cause: Environment variables not loaded or incorrect names.
Solution:
- Verify
.envfile exists in project root - Check variable names match exactly:
AWS_ACCESS_KEY_ID,AWS_ACCESS_SECRET - Restart Strapi completely
Error: "Bucket doesn't allow ACLs"
Cause: S3 bucket has ACLs disabled.
Solution:
- Go to S3 bucket → Permissions → Object Ownership
- Enable ACLs and select "Bucket owner preferred"
- Update
config/plugins.jsto useACL: 'private'
Error: "Invalid URL"
Cause: Database has null or malformed URLs after migration.
Solution:
- Run the
fix-s3-urls.jsscript - Restart Strapi
Error: "serializerMiddleware is not found"
Cause: AWS SDK version incompatibility.
Solution:
- Downgrade to
@aws-sdk/client-s3@3.621.0and@aws-sdk/s3-request-presigner@3.621.0
Images showing 404 errors
Cause: Files weren't migrated or don't exist locally.
Solution:
- Check if files exist:
ls -la public/uploads/ - Re-run migration script
- Check S3 bucket for uploaded files
Security Considerations
Private Bucket Configuration
✅ Do:
- Use
ACL: 'private'for all uploads - Generate presigned URLs with short expiration (1 hour default)
- Store credentials in
.envfile - Add
.envto.gitignore
❌ Don't:
- Use public ACLs
- Commit AWS credentials to version control
- Use overly long presigned URL expiration times
- Expose migration endpoints in production
Production Checklist
- [ ] Enable authentication on presigned URL endpoints (
auth: true) - [ ] Remove or secure migration scripts
- [ ] Use IAM roles instead of access keys (if on AWS infrastructure)
- [ ] Enable S3 bucket versioning for backup
- [ ] Set up CloudWatch alerts for S3 access
- [ ] Configure CORS on S3 bucket if needed for direct uploads
File Structure Summary
project-root/
├── config/
│ ├── plugins.js # S3 provider configuration
│ └── middlewares.js # Middleware registration
├── src/
│ ├── api/
│ │ └── upload/
│ │ ├── services/
│ │ │ └── s3-presigned.js
│ │ ├── controllers/
│ │ │ └── presigned.js
│ │ └── routes/
│ │ └── presigned.js
│ └── middlewares/
│ └── add-presigned-urls.js
├── scripts/
│ ├── migrate-media-to-s3-v5.js
│ ├── run-s3-migration.js
│ └── fix-s3-urls.js
└── .env # AWS credentials (never commit!)
What Files to Keep vs Remove
Keep Permanently ✅
-
config/plugins.js- S3 configuration -
config/middlewares.js- Middleware registration -
src/api/upload/services/s3-presigned.js- Presigned URL service -
src/middlewares/add-presigned-urls.js- Auto-attach middleware -
.env- Environment variables
Optional (Keep if needed) 🤔
-
src/api/upload/controllers/presigned.js- Manual endpoint controller -
src/api/upload/routes/presigned.js- Manual endpoint routes
Remove After Migration ❌
-
scripts/migrate-media-to-s3-v5.js- Migration script -
scripts/run-s3-migration.js- Migration runner -
scripts/fix-s3-urls.js- URL fix script -
public/uploads/*- Old local files (after verification)
Final Result
After completing all phases:
✅ New uploads automatically go to S3 /uploads folder
✅ All images are private and secure
✅ API responses contain presigned URLs that expire after 1 hour
✅ Frontend receives working URLs with no code changes
✅ Old images migrated to S3 successfully
✅ No data loss or corruption
Example API Response
{
"data": {
"id": 1,
"attributes": {
"title": "Article Title",
"image": {
"id": 5,
"name": "photo.jpg",
"hash": "photo_abc123",
"ext": ".jpg",
"provider": "aws-s3",
"url": "https://your-bucket.s3.us-east-1.amazonaws.com/uploads/photo_abc123.jpg?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=..."
}
}
}
}
The url field contains a temporary presigned URL that:
- Works immediately in browsers/apps
- Expires after 1 hour
- Regenerates automatically on each request
- Requires no frontend changes
Conclusion
This integration provides a production-ready S3 setup for Strapi v5 with:
- Complete security (private buckets + presigned URLs)
- Automatic URL management (no frontend changes)
- Safe migration path (existing files preserved)
- Organized storage (files in
/uploadsfolder)
All media assets are now securely stored in S3 while maintaining the same simple developer experience!
Top comments (1)
Really useful guide, Hrishikesh. The private S3 and presigned URL setup was especially helpful. We’re also working around Strapi and headless CMS integrations. Would be great to connect and exchange a few useful resources if you’re open to it.