DEV Community

Cover image for Complete Guide: Strapi v5 AWS S3 Integration with Presigned URLs
Hrishikesh Krishnan
Hrishikesh Krishnan

Posted on

Complete Guide: Strapi v5 AWS S3 Integration with Presigned URLs

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

  1. Overview
  2. Prerequisites
  3. Phase 1: Install Dependencies
  4. Phase 2: Configure AWS S3
  5. Phase 3: Create Presigned URL Service
  6. Phase 4: Auto-Attach Presigned URLs
  7. Phase 5: Migrate Existing Files
  8. Phase 6: Fix AWS SDK Compatibility
  9. Troubleshooting
  10. 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 /uploads folder
  • 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
Enter fullscreen mode Exit fullscreen mode

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

  1. 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.
  2. Enable ACLs

    • Go to bucket → PermissionsObject 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.
  3. Configure Bucket Policy (Optional but recommended)

    • Go to PermissionsBucket 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"
           }
         }
       }
     ]
   }
Enter fullscreen mode Exit fullscreen mode

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"
   }
Enter fullscreen mode Exit fullscreen mode
  • 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: {},
      },
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

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 /uploads folder 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
  },
});
Enter fullscreen mode Exit fullscreen mode

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 /uploads path 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,
    };
  },
};
Enter fullscreen mode Exit fullscreen mode

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 },
    },
  ],
};
Enter fullscreen mode Exit fullscreen mode

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);
};
Enter fullscreen mode Exit fullscreen mode

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
];
Enter fullscreen mode Exit fullscreen mode

Why register the middleware?

  • Makes Strapi execute our middleware on every HTTP response
  • global:: prefix means it's a custom middleware from src/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('');
};
Enter fullscreen mode Exit fullscreen mode

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);
})();
Enter fullscreen mode Exit fullscreen mode

Step 5.3: Run Migration

node scripts/run-s3-migration.js
Enter fullscreen mode Exit fullscreen mode

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);
})();
Enter fullscreen mode Exit fullscreen mode

When to use this script:

  • If migration set url: null and 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 .env file 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.js to use ACL: 'private'

Error: "Invalid URL"

Cause: Database has null or malformed URLs after migration.

Solution:

  • Run the fix-s3-urls.js script
  • Restart Strapi

Error: "serializerMiddleware is not found"

Cause: AWS SDK version incompatibility.

Solution:

  • Downgrade to @aws-sdk/client-s3@3.621.0 and @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 .env file
  • Add .env to .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!)
Enter fullscreen mode Exit fullscreen mode

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=..."
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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 /uploads folder)

All media assets are now securely stored in S3 while maintaining the same simple developer experience!

Top comments (0)