Чтобы отправлять загрузки Strapi в S3-совместимое хранилище, которое не принадлежит Amazon, установите @strapi/provider-upload-aws-s3, укажите provider: 'aws-s3' в config/plugins.js и задайте в providerOptions.s3Options четыре вещи: учётные данные, название региона, endpoint хранилища и forcePathStyle: true. Добавьте params.Bucket для бакета и baseUrl для адреса, с которого браузеры будут загружать файлы. Вот и вся настройка. Почти все сбои после этого происходят в одном из трёх мест: стиль адресации (SDK строит имя хоста, которого не существует), публичный URL (файлы загружаются, но не отображаются) или список управления доступом, который отправляется с каждой загрузкой (хранилище его отклоняет). В этой статье мы настроим провайдер как следует, разберём каждый ключ, а затем пройдём по ошибкам в том порядке, в котором вы, скорее всего, на них наткнётесь.
Предполагается, что Strapi уже работает. Об остальных решениях для продакшена - секреты, база данных, сборка админки, обновления - читайте в статье Strapi в продакшене, где тот же провайдер описан в одном разделе. Эта статья - длинная версия того раздела.
Что делает провайдер загрузок#
Плагин Upload в Strapi - это код, на котором стоят Media Library и endpoint /api/upload. Сам плагин файлы не хранит. Он передаёт каждый файл провайдеру - небольшому пакету с тремя задачами: загрузить буфер, загрузить поток и удалить. Провайдер по умолчанию пишет в public/uploads на диске сервера. Провайдер S3 пишет вместо этого в бакет.
Путь файла важен для диагностики, поэтому держите его в голове:
- Редактор перетаскивает файл в Media Library. Браузер отправляет его в Strapi, а не в бакет.
- Strapi принимает multipart-запрос, читает файл и для изображений создаёт дополнительные размеры (варианты
thumbnail_,small_,medium_иlarge_в зависимости от ваших breakpoints). - Для каждого файла и каждого варианта провайдер отправляет из процесса Strapi на endpoint один запрос S3
PutObject. - Strapi сохраняет получившийся публичный URL в своей базе данных, в поле
urlзаписи о файле. - Когда страница или админка показывает изображение, браузер загружает этот URL напрямую из хранилища. Strapi в этом пути не участвует.
Из этого следуют два вывода. Во-первых, для загрузок не нужна настройка CORS на бакете, потому что браузер в него никогда не пишет; CORS важен, только если вы позже добавите прямые загрузки из браузера через presigned URL (см. presigned URL в S3). Во-вторых, серверу со Strapi нужен доступ к endpoint хранилища, а вашим посетителям - к тому адресу, на который указывает baseUrl. Это могут быть два разных адреса, и главное в настройке - не перепутать их.
Что нужно от хранилища#
Прежде чем трогать Strapi, соберите пять значений из вашего S3-совместимого хранилища:
| Значение | Пример | Для чего |
|---|---|---|
| URL endpoint | https://s3.example.com | endpoint |
| Ключ доступа и секрет | AKIA... и длинная строка | credentials |
| Имя бакета | strapi-media | params.Bucket |
| Название региона | us-east-1 | region |
| Публичный базовый URL | https://s3.example.com/strapi-media | baseUrl |
Регион - то, на чём чаще всего застревают. Amazon использует его, чтобы выбрать дата-центр и построить подпись запроса. У хранилища не от Amazon выбирать регион не из чего, но SDK всё равно требует значение, потому что подпись включает регион. Правило такое: берите то, что указало хранилище, а если оно ничего не говорит, на большинстве работает любое корректное название вроде us-east-1. Важно, чтобы везде, где вы обращаетесь к хранилищу, использовалась одна и та же строка. Почему так, объясняет статья S3 path-style и virtual-hosted URL и что такое регионы.
Сначала проверьте значения чем-то другим, а не Strapi. Если AWS CLI не может показать содержимое бакета, Strapi им тоже не управится, а CLI выдаёт куда более понятную ошибку. Скажите CLI использовать адресацию path-style и выведите список:
$ aws configure set default.s3.addressing_style path$ export AWS_ACCESS_KEY_ID=...$ export AWS_SECRET_ACCESS_KEY=...$ aws --endpoint-url https://s3.example.com --region us-east-1 s3 ls s3://strapi-media/Пустой бакет ничего не выводит и завершается без ошибки - это успех. Неверный ключ даёт InvalidAccessKeyId или SignatureDoesNotMatch; неверный бакет - NoSuchBucket. Полный набор команд, если захотите пойти дальше, есть в статье S3-хранилище и AWS CLI.
Установка провайдера#
Из папки проекта Strapi:
$ npm install @strapi/provider-upload-aws-s3Несмотря на название, это провайдер для любого S3-совместимого сервиса. Отдельного «универсального S3»-пакета от Strapi нет. Существуют community-провайдеры для конкретных вендоров, но для endpoint под вашим контролем лучше официальный, потому что он следует за релизами Strapi. Мажорная версия провайдера должна совпадать с мажорной версией Strapi; пакет публикуется с тем же версионированием, что и сам Strapi, а провайдер от более старой мажорной версии - частая причина запутанных ошибок после обновления. Проверьте установленную версию командой npm ls @strapi/provider-upload-aws-s3 и сравните её с версией @strapi/strapi в package.json.
Конфигурация, ключ за ключом#
Создайте или отредактируйте config/plugins.js (или config/plugins.ts в проекте на TypeScript - структура та же, только с export default):
module.exports = ({ env }) => ({ upload: { config: { provider: 'aws-s3', providerOptions: { baseUrl: env('S3_PUBLIC_URL'), rootPath: env('S3_ROOT_PATH', ''), s3Options: { credentials: { accessKeyId: env('S3_ACCESS_KEY_ID'), secretAccessKey: env('S3_SECRET_ACCESS_KEY'), }, region: env('S3_REGION', 'us-east-1'), endpoint: env('S3_ENDPOINT'), forcePathStyle: true, params: { Bucket: env('S3_BUCKET'), }, }, }, actionOptions: { upload: {}, uploadStream: {}, delete: {}, }, }, },});Имена внутри env() вы выбираете сами; структура вокруг них неизменна. Вот что делает каждый ключ:
| Ключ | Что делает |
|---|---|
provider | Должен быть 'aws-s3', короткое имя пакета |
providerOptions.baseUrl | Адрес, который хранится в базе и используется браузерами |
providerOptions.rootPath | Префикс, чтобы несколько приложений делили один бакет |
s3Options.credentials | Ключ доступа и секрет: accessKeyId и secretAccessKey |
s3Options.region | Нужен SDK, даже если хранилище его игнорирует |
s3Options.endpoint | URL хранилища; без него SDK обращается к Amazon |
s3Options.forcePathStyle | Бакет в пути, а не в имени хоста |
s3Options.params.Bucket | Бакет, в который попадает каждый объект |
actionOptions | Дополнительные параметры для каждого вызова upload, uploadStream или delete |
Всё, что лежит в s3Options, передаётся в конструктор S3Client из AWS SDK for JavaScript v3, поэтому имена совпадают с документацией самого SDK. Это полезно знать: в s3Options можно положить любую опцию, которую принимает клиент SDK, в том числе опции контрольных сумм, о которых речь пойдёт ниже.
О rootPath стоит сказать отдельно. Без него объекты ложатся в корень бакета с сгенерированным ключом вроде thumbnail_cat_4f2a1b.jpg. С rootPath: 'cms' ключ становится cms/thumbnail_cat_4f2a1b.jpg. Его стоит задать, если бакет делится с чем-то ещё, и в первый же день это ничего не стоит. Позднее изменение не переносит существующие объекты, а сохранённые URL продолжают указывать на старые ключи.
Переменные окружения#
Держите учётные данные не в файле конфигурации, а в .env в папке проекта, рядом с секретами Strapi:
S3_ENDPOINT=https://s3.example.comS3_REGION=us-east-1S3_BUCKET=strapi-mediaS3_ACCESS_KEY_ID=...S3_SECRET_ACCESS_KEY=...S3_PUBLIC_URL=https://s3.example.com/strapi-mediaS3_ROOT_PATH=Никогда не коммитьте этот файл. Strapi читает .env при запуске, поэтому после правки перезапустите сервер; пересборка админки не нужна, потому что эти значения использует серверный процесс, а не клиентский бандл. О привычках, которые не дают .env попасть в репозиторий, рассказывает статья переменные окружения и секреты. Если вы размещаете Strapi на RE:NODE, файловый менеджер и SFTP открывают папку проекта, так что файл можно править в браузере - см. SFTP и файловый менеджер.
Одна деталь, на которой спотыкаются: пустое значение вроде S3_ROOT_PATH= - это пустая строка, и env('S3_ROOT_PATH', '') возвращает её как есть. Здесь это нормально. Но пустой S3_ENDPOINT= - тоже пустая строка, а SDK считает это отсутствием endpoint и тихо отправляет запросы в Amazon. Если загрузки падают с сообщением InvalidAccessKeyId от хранилища, которое никогда не слышало о вашем ключе, проверьте, что переменная действительно задана и названа так, как её читает конфиг.
Почему path-style и что бывает без него#
Получив endpoint и бакет, AWS SDK строит один из двух URL. Virtual-hosted, по умолчанию, помещает бакет в имя хоста: https://strapi-media.s3.example.com/cat.jpg. Path-style помещает его в путь: https://s3.example.com/strapi-media/cat.jpg. Virtual-hosted требует DNS-записи для каждого имени бакета и сертификата, который их все покрывает. У хранилища, которое работает за одним именем хоста, нет ни того ни другого, поэтому значение SDK по умолчанию даёт имя, которое не разрешается.
Сбой узнаваем. Если forcePathStyle отсутствует или равен false, то при первой загрузке в логе Strapi будет что-то такое:
error: getaddrinfo ENOTFOUND strapi-media.s3.example.comПосмотрите на имя хоста в сообщении. Если оно начинается с имени вашего бакета, SDK использует virtual-hosted, и исправление - forcePathStyle: true в s3Options. Если имя хоста просто s3.example.com, а ошибка другая, адресация верна и проблема в другом. Эта одна проверка убирает половину догадок.
Есть и более тонкий вариант. Хранилище с wildcard-DNS и wildcard-сертификатом может принимать virtual-hosted запросы, и загрузки заработают без этого параметра. Если позже вы перейдёте на хранилище без этого, та же конфигурация сломается. Задавайте forcePathStyle: true в любом случае; path-style работает на любом S3-совместимом хранилище, в том числе у Amazon, который по-прежнему принимает его для существующих бакетов.
baseUrl: почему файлы загружаются, но не отображаются#
Провайдер пишет объекты через endpoint, но URL, который он сохраняет в базе, строится из baseUrl. Они разделены намеренно: адрес, на который вы пишете, часто не тот, с которого вы хотите отдавать файлы миру. CDN, прокси с более красивым именем хоста или публичный путь на том же хосте - всё это обычные случаи.
Если baseUrl не задан, провайдер строит URL по представлению SDK о расположении бакета. С собственным endpoint и адресацией path-style это обычно endpoint плюс бакет, и это может оказаться верным, но может быть и адресом, доступным только серверу Strapi, например внутренним или по простому HTTP. Явный baseUrl убирает гадание.
При адресации path-style в базовом URL есть бакет: https://s3.example.com/strapi-media. Сохранённый URL - это база плюс ключ, так что файл с ключом thumbnail_cat_4f2a1b.jpg отдаётся с https://s3.example.com/strapi-media/thumbnail_cat_4f2a1b.jpg. Откройте этот адрес в приватном окне браузера, без учётных данных, и посмотрите, загрузится ли изображение. Эта единственная проверка разделяет три типичных сбоя:
- Браузер предупреждает о смешанном содержимом или блокирует изображение. Админка работает по HTTPS, а сохранённый URL - обычный
http://. Браузеры блокируют HTTP-изображения на HTTPS-странице. Используйте вbaseUrlимя с HTTPS. - URL возвращает `403` или `AccessDenied`. Объект существует, но анонимное чтение запрещено. См. следующий раздел.
- URL возвращает `404` или `NoSuchKey`. URL не совпадает с ключом. Проверьте
rootPathи то, есть ли имя бакета вbaseUrl.
У Media Library есть и четвёртый, специфичный для Strapi вариант сбоя: файлы открываются в новой вкладке, но миниатюры в админке сломаны. Это собственная Content Security Policy Strapi отказывается загружать изображения с хоста, о котором ей не сказали, и исправление лежит в config/middlewares.js. В руководстве по Strapi в продакшене есть точный блок strapi::security с img-src и media-src; добавьте в оба хост вашего baseUrl.
Публичные и приватные файлы, ACL#
По умолчанию провайдер загружает каждый объект с ACL public-read, благодаря чему ссылки baseUrl работают для анонимных посетителей. Для картинок маркетингового сайта это правильное поведение. Для всего, что не должно быть публичным, оно неверно, и хранилище к тому же может его отклонить.
ACL - это заголовок с правами на отдельный объект, который отправляется вместе с PutObject. Amazon отходит от них: новые бакеты Amazon по умолчанию блокируют ACL и опираются на политики бакета. Некоторые S3-совместимые хранилища их вообще не реализовали и отвечают на загрузку с ACL ошибкой AccessControlListNotSupported или общим NotImplemented. Если загрузки падают с жалобой на ACL, а учётные данные в порядке, причина в этом. Провайдер читает ACL из params, так что управлять им можно там:
params: { ACL: 'private', signedUrlExpires: 15 * 60, Bucket: env('S3_BUCKET'),},С ACL: 'private' объекты нельзя читать анонимно, и провайдер переходит на подписанные URL: когда Strapi возвращает файл в ответе API, он подписывает URL с ограниченным сроком и возвращает его вместо обычного. signedUrlExpires - срок жизни в секундах. Две практические заметки:
- Подписанные URL истекают, поэтому страница, которая кэширует ответ API дольше, чем
signedUrlExpires, покажет сломанные изображения. Согласуйте время жизни кэша со временем жизни подписи или оставьте бакет публичным для ресурсов, которые относятся к публичным страницам. - Подпись охватывает имя хоста. Подписанный URL строится из
endpoint, поэтому этот endpoint должен быть доступен из браузера посетителя. Внутренний адрес, который работает для сервера Strapi, подпишет URL, которые никто другой не откроет. Почему так, объясняют presigned URL в S3.
Умеет ли конкретное хранилище делать бакет или путь читаемым без ACL, определяет само хранилище, а не Strapi: одни используют политики бакета, у других есть переключатель «public» на бакет, у третьих нет ни того ни другого. Выясните, что поддерживает ваше, и проверьте анонимный доступ командой curl -I на загруженном файле, прежде чем говорить редакторам, что всё готово. То же относится к хранилищу, которое отклоняет ACL: если вы не можете отправить ACL public-read и не можете задать политику, рабочий путь - подписанные URL с ACL: 'private'. Точное поведение при null или отсутствующем ACL смотрите в README провайдера для вашей установленной версии, потому что между релизами оно менялось.
Контрольные суммы загрузок в новых версиях SDK#
Провайдер построен на AWS SDK for JavaScript v3. С начала 2025 года SDK по умолчанию добавляет к загрузкам контрольную сумму на основе CRC. Некоторые S3-совместимые серверы не принимают эти дополнительные заголовки, и сбой выглядит странно: Media Library нормально показывает объекты, нормально их удаляет, а каждая загрузка падает с SignatureDoesNotMatch, MissingContentLength или жалобой на контрольные суммы или x-amz-content-sha256.
У клиента SDK есть две опции, которые сводят новое поведение к «только когда операция этого требует». Поскольку s3Options передаётся прямо в клиент, они лежат там:
s3Options: { credentials: { /* ... */ }, region: env('S3_REGION', 'us-east-1'), endpoint: env('S3_ENDPOINT'), forcePathStyle: true, requestChecksumCalculation: 'WHEN_REQUIRED', responseChecksumValidation: 'WHEN_REQUIRED', params: { Bucket: env('S3_BUCKET') },},Добавляйте их, только если видите этот симптом: они есть в релизах SDK начиная с начала 2025 года, а более старый SDK неизвестные опции игнорирует. Если установленный провайдер тянет более старый SDK, чище обновить провайдер. Та же проблема встречается и у клиента командной строки, и у других SDK, а S3 из Node.js и Python показывает эквивалентные настройки там. Полезный признак: если aws s3 cp работает с этим endpoint, а Strapi нет, прежде всего сравните версии CLI и SDK.
Проверка и как понять, что произошло на самом деле#
Когда конфигурация на месте, перезапустите Strapi и загрузите одно изображение через Media Library. Проверяйте по порядку:
- Лог сервера. Запустите Strapi в терминале или читайте консоль в панели. Любая ошибка провайдера пишется в момент загрузки, с настоящим сообщением. Не верьте общему уведомлению «Internal Server Error» в админке.
- Бакет. Выведите его список через CLI. Вы должны увидеть оригинал и, для изображений, варианты
thumbnail_,small_,medium_иlarge_, каждый отдельным объектом. Используйтеaws --endpoint-url https://s3.example.com s3 ls s3://strapi-media/, как раньше. - URL. Нажмите на файл в Media Library, скопируйте его ссылку и откройте в приватном окне.
- Удаление. Удалите тестовый файл в админке и снова выведите список. Вызов
deleteпровайдера удаляет оригинал и все варианты; если объекты остались, скорее всего, у учётных данных нет права на удаление, а это легко упустить, если ключ создавали только для записи.
Шаг 4 - тот, который люди пропускают. Ключ, который может писать, но не удалять, оставляет в бакете «сирот», которых никто не видит, пока их не покажут счёт или квота.
Перенос уже существующих файлов#
Смена провайдера ничего не переносит. Файлы, загруженные раньше, по-прежнему имеют в базе URL /uploads/... и по-прежнему лежат в public/uploads. Есть три честных варианта:
- Оставить как есть. Держите
public/uploadsна диске сервера, а новые файлы отправляйте в бакет. Старые ссылки продолжают работать, пока папка на месте. Это самый безопасный выбор, и для небольшой библиотеки он подходит. - Скопировать и переписать. Синхронизируйте
public/uploadsс бакетом инструментом вродеrcloneили AWS CLI (см. rclone и S3-хранилище), затем обновитеurl(и URL по форматам в JSON-колонкеformats) в таблицеfilesна новую базу. Это изменение базы данных, поэтому сначала сделайте backup Strapi и проверьте на копии. Настоящие названия колонок берите из своей схемы, а не из руководства. - Экспорт и импорт. Команды
strapi exportиstrapi importпереносят контент и, с нужными флагами, медиа. Это поддерживаемый способ переноса между окружениями, но перед тем как полагаться на него при миграции, проверьте, что импорт в вашей версии пишет через настроенный провайдер.
Что бы вы ни выбрали, делайте это в тихий час и держите backup и базы, и public/uploads, пока не посмотрите на результат в админке. Миграция, проверенная только загрузкой нового изображения, старые не проверила.
Как держать бакет и базу в согласии#
Когда загрузки живут в бакете, база и бакет становятся двумя вещами, которые должны совпадать. Backup одного без другого даёт либо записи без файлов, либо файлы без записей.
- Backup Strapi (копия базы или
strapi export) хранит ссылки. Изображений в нём нет. - Копия бакета хранит изображения. Какие из них используются, она не знает.
- Восстанавливайте оба из одного дня или смиритесь с тем, что часть записей будет указывать на отсутствующие файлы.
У бакета есть и своя надёжность, о которой стоит подумать. Если хранилище держит одну копию в одном месте, это не backup чего-либо, а основная копия, и ей нужен собственный backup. О том, как это рассчитать, читайте в правиле 3-2-1 для S3-хранилища, а о способе его снять - в restic и backup в S3.
Strapi с тарифом S3 на RE:NODE#
На RE:NODE Strapi - приложение в один клик на странице хостинга приложений, оно устанавливается и собирается при первом запуске сервера, с данными в SQLite на сервере. Тариф объектного хранилища S3 - это отдельный сервер, который даёт то, что нужно этой статье: S3-совместимый endpoint, ключ доступа и секретный ключ, сгенерированные для этого сервера, и уже созданный первый бакет. Он использует адресацию path-style и принимает любое название региона, поэтому forcePathStyle: true и регион вроде us-east-1 - правильные значения. Endpoint отвечает по простому HTTP на своём порту, а HTTPS идёт через слот прокси тарифа: направьте имя хоста на показанный адрес, и сертификат будет выпущен и продлеваться сам. Используйте это имя с HTTPS в endpoint и baseUrl, иначе админка заблокирует изображения как смешанное содержимое.
В тарифы S3 слоты backup не входят, а хранилище - это одна копия на NVMe в одном месте, без репликации. Для медиа, которые можно воссоздать, этого достаточно, а всё незаменимое стоит копировать ещё куда-нибудь. Слоты backup сервера Strapi покрывают папку проекта и файл SQLite, но не бакет. Работает ли анонимное чтение бакета и как его включить, эта статья обещать за вас не может; проверьте загруженный файл в приватном окне, как описано выше.
Диагностика#
| Симптом | Вероятная причина |
|---|---|
ENOTFOUND bucket.host | forcePathStyle отсутствует или стоит не там |
InvalidAccessKeyId от вашего хранилища | Endpoint пуст, запрос ушёл в Amazon |
NoSuchBucket | Неверный params.Bucket или бакет не создан |
AccessControlListNotSupported | Хранилище отклоняет ACL; задайте ACL: 'private' |
SignatureDoesNotMatch только при загрузке | Новые контрольные суммы по умолчанию; см. раздел о них |
SignatureDoesNotMatch везде | Неверный секрет или прокси переписал заголовок Host |
| Загрузки работают, миниатюры сломаны | img-src в config/middlewares.js |
| Изображения блокируются в админке | baseUrl с http:// при HTTPS-админке |
413 при загрузке | Лимит тела запроса на обратном прокси, не Strapi и не хранилище |
403 на публичном URL | Объект не читается публично |
Несколько замечаний, которые не помещаются в таблицу. SignatureDoesNotMatch на каждом запросе значит, что учётные данные неверны или подпись ломается по дороге; обычный виновник по дороге - обратный прокси перед хранилищем, который не передаёт исходный заголовок Host, ведь подпись его охватывает. Если это происходит только при загрузках, думайте о контрольных суммах раньше, чем об учётных данных.
Что до 413, лимит - это настройка того прокси, который стоит перед Strapi. Загрузки из админки идут через него. Увеличьте там размер тела и подумайте о собственном sizeLimit Strapi в config плагина upload, чтобы редакторы получали понятное сообщение, а не оборванное соединение.
Если Strapi не запускается после изменения, с сообщением, что провайдер не удаётся загрузить, значит, пакет не установлен в той папке, из которой работает Strapi: выполните npm install там, а не на своём ноутбуке, и перезапустите. И помните: изменение config/plugins.js требует перезапуска, но и изменения Content Security Policy админки в config/middlewares.js тоже требуют перезапуска; пересборка не нужна ни в том, ни в другом случае.
FAQ#
Нужно ли настраивать CORS на бакете для загрузок Strapi?
Нет. Загрузки идут из браузера в Strapi и из Strapi в бакет, так что браузер в бакет не пишет и CORS не применяется. CORS нужен, только если вы добавите прямые загрузки из браузера по presigned URL или будете загружать из бакета шрифты или данные canvas.
Какое название региона использовать для хранилища не от Amazon?
То, которое указано в документации хранилища. Если там ничего нет, используйте us-east-1. SDK нужно какое-то значение для подписи запросов, а хранилище не от Amazon принимает то, что от него ждут. Держите одну и ту же строку в Strapi, CLI и всех остальных клиентах.
Можно ли использовать провайдер S3 с непубличным бакетом?
Да. Задайте ACL: 'private' в s3Options.params, и провайдер будет возвращать подписанные URL, которые истекают через signedUrlExpires секунд. Кэшируйте ответы API на меньшее время и убедитесь, что endpoint, которым подписываются URL, доступен из браузера посетителя.
Почему загрузки работают, а миниатюры в Media Library остаются пустыми?
Content Security Policy Strapi разрешает изображения только с собственного origin и из короткого списка хостов. Добавьте хост вашего baseUrl в img-src и media-src в config/middlewares.js и перезапустите.
Перенесёт ли переход на S3 мои существующие медиа?
Нет. Существующие файлы сохраняют URL /uploads/... и остаются на диске сервера. Скопируйте их в бакет и обновите сохранённые URL либо оставьте как есть и пусть только новые загрузки идут в бакет.
Удаляет ли Strapi файлы из бакета, когда я удаляю их в админке?
Да, через вызов delete провайдера, который удаляет оригинал и его варианты других размеров. У ключа доступа должно быть право удалять объекты, а ключ только для записи оставит файлы на месте.


