Set up Amazon S3 Upload Provider Plugin for Your Strapi App

In this tutorial, I will show you how to set up the Amazon S3 Upload Provider Plugin for your Strapi App.


This is a companion discussion topic for the original entry at https://strapi.io/blog/how-to-set-up-amazon-s3-upload-provider-plugin-for-our-strapi-app

Hello

thank you very much for the blog post.

I would like to suggest few point to go forward:

  1. It’s not a good practice to go live with a public S3 bucket: for high availability and security it’s better to have cloudfront in front of S3
  2. It’s not a good practice to access the S3 bucket with a IAM user: it’s highly recommended to assigner a role to the EC2 instance.
  3. (Optional) Do do not deploy on EC2 choose something more cloud “oriented”: ECS Fargate, Beanstalk, App Run

I am trying build such AWS infrastructure here … with terraform: GitHub - pagopa/cms-infra: Infrastructure to host the PagoPa headless CMS

it’s still a work in progress.

Hi

Thank you for the suggestions.

FYI, this tutorial is not meant to show you how to host Strapi on an AWS EC2 instance or any other AWS hosting solution. That requires a separate article. This tutorial is meant to showcase S3 as an alternative to hosting your Strapi app media assets locally. Very useful if you host your Strapi app on PAAS platforms with ephemeral file systems like Heroku, Railway, etc.

Hello I’ve been following this guide and I’ve got uploading to the S3 bucking working. However I’m having an issue with getting uploaded .jpg files to display from the S3 bucket in strapi admin. I get the following error after the URL to the img on aws.

because it violates the following Content Security Policy directive: “img-src ‘self’ data: blob: dl.airtable.com .s3.amazonaws.com”.

I’ve done a fair amount of research and generally it seems to be a middleware configuration issue for most people. I’ve tried all the commonly found suggestions with no luck. This is my middleware config

module.exports = [
      'strapi::errors',

      {
        name: 'strapi::security',
        config: {
          contentSecurityPolicy: {
            useDefaults: true,
            directives: {
              'connect-src': ["'self'", 'https:'],
              'img-src': [
                "'self'",
                'data:',
                'blob:',
                'dl.airtable.com',
                `${process.env.AWS_BUCKET_NAME}.s3.amazonaws.com`,
              ],
              'media-src': [
                "'self'",
                'data:',
                'blob:',
                'dl.airtable.com',
                `${process.env.AWS_BUCKET_NAME}.s3.amazonaws.com`,
              ],
              upgradeInsecureRequests: null,
            },
          },
        },
      },
      /* End of snippet */
      'strapi::cors',
      'strapi::poweredBy',
      'strapi::logger',
      'strapi::query',
      'strapi::body',
      'strapi::session',
      'strapi::favicon',
      'strapi::public',
    ];

Any ideas would be appreciated. Thanks

have you tried adding the region like this: yourBucketName.s3.yourRegion.amazonaws.com

Thanks for the reply. I am actually back to that and I got images to display later last night by unchecking the other 2 boxes in the Block Public Access fields of the aws bucket. Do you have any thoughts on that. Is it ok to do this?

As far as I understand we don’t support private s3 buckets.

I have a similar issue, but in my case there’s no public access issue in AWS to fix. The image URLs that end up in Postgres simply lack the region.

Followed the tutorial at: How to Set up Amazon S3 Upload Provider Plugin for Your Strapi App

Expected example URL: https://my-bucket.s3.us-east-1.amazonaws.com/my-image.png
Actually generated URL: https://my-bucket.s3.amazonaws.com/my-image.png

Relevant parts of my ./backend/config/plugins.js:

upload: {
	config: {
		provider: 'aws-s3',
		providerOptions: {
			accessKeyId: env('AWS_ACCESS_KEY_ID'),
			secretAccessKey: env('AWS_ACCESS_SECRET'),
			region: env('AWS_REGION'),
			params: {
				Bucket: env('AWS_BUCKET'),
			},
		},
	      // These parameters could solve issues with 
		actionOptions: {
			upload: {ACL: null},
			uploadStream: {ACL: null},
			delete: {},
		},
	},
},

Relevant parts of my ./backend/config/middlewares.js:

{
	name: "strapi::security",
	config: {
		contentSecurityPolicy: {
			useDefaults: true,
			directives: {
				"connect-src": ["'self'", "https:"],
				"img-src": [
					"'self'",
					"data:",
					"blob:",
				//	"dl.airtable.com",
					"my-bucket.s3.us-east-1.amazonaws.com",
				],
				"media-src": [
					"'self'",
					"data:",
					"blob:",
				//	"dl.airtable.com",
					"my-bucket.s3.us-east-1.amazonaws.com",
				],
				upgradeInsecureRequests: null,
			},
		},
	},
},

Any hint would be appreciated! :heart:

I think there is an exception for US east that the region is not included.

Thanks for replying… can you elaborate a little bit? Who made the exception? Strapi or AWS? Why would it not be “included”? Included in what list? Maintained by whom?

Ok I figured it out. Turns out I was wrong about the region being a requirement in the URL. In fact, AWS is smart enough to figure out the region even if omitted and just redirects to the “final” URL with region in it. So Strapi is not wrong to omit the region in the stored image URLs in the database.

The reason why my set-up didn’t work is that the tutorial is actually incorrect in two important ways (which may be new conditions as of now 01/2023):

1.) The suggested AWS security settings don’t actually enable default public access for all objects uploaded to the S3 bucket.

I followed these instructions instead to make the bucket and all new objects in it, truly public:

2.) Also, the suggested middlewares.js > strapi::security settings do not work for me.

They effectively prevent the display of objects from your bucket, even if AWS S3 is configured to allow public access to all new objects.

The correct settings actually omit the region. These settings are used as a kind of security filter before displaying the images. Since they are stored in the database without region, the “match filter” applied by security needs to also spell out the hostname without region, like so:

config: {
	contentSecurityPolicy: {
		useDefaults: true,
		directives: {
			"connect-src": ["'self'", "https:"],
			"img-src": [
				"'self'",
				"data:",
				"blob:",
				`${process.env.AWS_BUCKET}.s3.amazonaws.com`,
			],
			"media-src": [
				"'self'",
				"data:",
				"blob:",
				`${process.env.AWS_BUCKET}.s3.amazonaws.com`,
			],
			upgradeInsecureRequests: null,
		},
	},
},

Hope this helps someone who’s stuck with a similar problem.

For me it’s working on my localhost but when i try to upload files on the deployed production admin I get an Internal server error. Does anyone know how to fix this?

1 Like

Hi, Sorry for not responding sooner. I was on vacation. I will take a look at this and make sure the documentation for the providers is updated accordingly.

I have the same problem, tearing my hairs out at the moment.

I added the plugins.js to the config/env/production directory and added the APP KEYS to Digital Ocean but to no avail.

This code in the middleware finally solve it for me:

module.exports = ({ env }) => [
  "strapi::errors",
  {
    name: "strapi::security",
    config: {
      contentSecurityPolicy: {
        useDefaults: true,
        directives: {
          "connect-src": ["'self'", "https:"],
          "img-src": [
            "'self'",
            "data:",
            "blob:",
            "dl.airtable.com",
            `https://${env("AWS_BUCKET")}.s3.${env("AWS_REGION")}.amazonaws.com/`,
          ],
          "media-src": [
            "'self'",
            "data:",
            "blob:",
            "dl.airtable.com",
            `https://${env("AWS_BUCKET")}.s3.${env("AWS_REGION")}.amazonaws.com/`,
          ],
          upgradeInsecureRequests: null,
        },
      },
    },
  },
  "strapi::cors",
  "strapi::poweredBy",
  "strapi::logger",
  "strapi::query",
  "strapi::body",
  "strapi::session",
  "strapi::favicon",
  "strapi::public",
];

I’ll update the article and try to address some of the issues raised.

Is there any way to use this plugin with a self hosted minio s3 bucket?

Nope since this provider uses the aws-sdk

I just thought I’d add a working example with the config I have made to my code to get it to work seeing as I had a lot of trouble getting things to display correctly on both Strapi and my front-end.
I use cloudformation to deploy my service on the cloud so I have attached a couple of code extract in the hope it helps someone.

I have added the following variables in my .env file. Note for local development I still use the local upload provider.

CORS_ALLOWED_ORIGINS=["*"]

#AWS + s3 upload provider variables
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_REGION=
UPLOAD_AWS_BUCKET_NAME=
UPLOAD_IMAGE_PROVIDER=local
UPLOAD_MEDIA_SOURCE=["'self'", "data:", "blob:", "dl.airtable.com"]

This is my middlewares.ts config file:

export default ({ env }) => [
  "strapi::errors",
  {
    name: "strapi::security",
    config: {
      contentSecurityPolicy: {
        useDefaults: true,
        directives: {
          "connect-src": ["'self'", "https:"],
          "img-src": env.array("UPLOAD_MEDIA_SOURCE", ["*"]),
          "media-src": env.array("UPLOAD_MEDIA_SOURCE", ["*"]),
          upgradeInsecureRequests: null,
        },
      },
    },
  },
  {
    name: "strapi::cors",
    config: {
      origin: env.array("CORS_ALLOWED_ORIGINS", ["*"]),
    },
  },
  "strapi::poweredBy",
  "strapi::logger",
  "strapi::query",
  "strapi::body",
  "strapi::session",
  "strapi::favicon",
  "strapi::public",
];

Finally, this is my plugins.ts files

export default ({ env }) => ({
  "users-permissions": {
    config: {
      jwt: {
        expiresIn: "7d",
      },
    },
  },
  upload: {
    config: {
      provider: env("UPLOAD_IMAGE_PROVIDER", "local"),
      providerOptions: {
        accessKeyId: env("AWS_ACCESS_KEY_ID"),
        secretAccessKey: env("AWS_SECRET_ACCESS_KEY"),
        region: env("AWS_REGION"),
        params: {
          Bucket: env("UPLOAD_AWS_BUCKET_NAME"),
        },
      },
    },
  },
});

These are the values I pass into my task definition - this is a simple AWS::ECS::TaskDefinition to host strapi using Fargate:

          Environment:
            - Name: NODE_ENV
              Value: !Ref Environment
            - Name: CORS_ALLOWED_ORIGINS
              Value: !Sub "[https://www.${DomainAddress}, https://${ApiDomainAddress}]"
            - Name: DATABASE_NAME
              Value: !Sub "strapi_${Environment}"
            - Name: DATABASE_PORT
              Value: !Ref DatabasePort
            - Name: DATABASE_HOST
              Value: !GetAtt DBInstance.Endpoint.Address
            - Name: DATABASE_USERNAME
              Value: !Sub "{{resolve:secretsmanager:${RDSInstanceRotationSecret}::username}}"
            - Name: DATABASE_PASSWORD
              Value: !Sub "{{resolve:secretsmanager:${RDSInstanceRotationSecret}::password}}"
            - Name: UPLOAD_AWS_BUCKET_NAME
              Value: !Ref StrapiImageUploadBucket
            - Name: UPLOAD_MEDIA_SOURCE
              Value: !Sub "['self', data:, blob:, dl.airtable.com, ${StrapiImageUploadBucket.RegionalDomainName}]"
            - Name: UPLOAD_IMAGE_PROVIDER
              Value: !Ref UploadImageProvider

The main thing to consider is that the AWS credentials and region are read from the Task’s AWS::IAM::Role so I don’t pass these into the task itself. The bucket declaration itself is a very basic AWS::S3::Bucket with mostly the default configuration. See below:

  StrapiImageUploadBucket:
    Type: AWS::S3::Bucket
    DeletionPolicy: Delete
    Properties:
      BucketName: !Sub "${ServiceName}-strapi-image"
      BucketEncryption:
        ServerSideEncryptionConfiguration:
          - ServerSideEncryptionByDefault:
              SSEAlgorithm: "AES256"
      LifecycleConfiguration:
        Rules:
          - Id: !Sub "${ServiceName}-strapi-image"
            NoncurrentVersionExpirationInDays: !Ref BucketLifecycleRetention
            Status: Enabled

Happy to provide any help or further examples should anybody require these.

Cheers