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 (0)