
Статья подготовлена совместно с экспертом
Елизавета Белоконова, менеджер продукта

Статический сайт на S3 можно собрать за вечер, но первые проблемы обычно появляются уже после загрузки файлов. Сборка проходит без ошибок: next build или hugo build кладут готовые файлы в папку, они уезжают в хранилище, а вместо страницы браузер показывает 403. Или сайт открывается, но только по http://, потому что сертификат настроен не на том слое.
В этой статье мы приводим маршрут от сборки Next.js или Hugo до собственного домена с HTTPS. Разберём, как включить хостинг на бакете, не получить 403, залить сборку из GitHub Actions и GitLab CI, подключить CDN и настроить кэш.

Елизавета Белоконова, менеджер продукта
У статического сайта на S3 объектное хранилище берёт на себя роль веб-сервера: браузер запрашивает объект по ключу, бакет отдаёт его по HTTP или HTTPS. Виртуальная машина с Nginx в такой схеме не нужна. Файлы хранятся и раздаются из одного сервиса, а сайт настраивается на уровне бакета.
Хостинг статических сайтов в VK Object Storage — штатная функция бакета. В конфигурации задаются индексная страница, страница ошибки и правила перенаправления. Такой бакет подходит для сайта-визитки, личного блога, лендинга, документации и перенаправления запросов.
Плюсы статики на объектном хранилищ:
Серверной логики в бакете нет, поэтому формы обратной связи, авторизация и работа с базой данных потребуют внешних сервисов. Для статической части сайта достаточно настроить бакет и CDN.
Первые проблемы обычно возникают из-за прав доступа, имени бакета и настройки индексного документа. Их нужно проверить до загрузки файлов.
Бакету и объектам нужен публичный ACL, без него запросы к объектам будут завершаться ошибкой 403. Этот же ACL нужен, если бакет будет источником CDN, поэтому лучше включить его сразу.
По умолчанию AWS CLI обращается к серверам Amazon. При работе с VK Cloud в каждой команде нужно указывать --endpoint-url. Если забыть этот параметр, CLI уйдёт не туда.
# Ключи доступа берутся в личном кабинете, регион — ru-msk aws configure # Создать бакет и проверить, что CLI смотрит в VK Cloud, а не в Amazon aws s3 mb s3://my-site-bucket \ --endpoint-url https://hb.ru-msk.vkcloud-storage.ru aws s3 ls \ --endpoint-url https://hb.ru-msk.vkcloud-storage.ru # Открыть публичный доступ на чтение aws s3api put-bucket-acl \ --bucket my-site-bucket \ --acl public-read \ --endpoint-url https://hb.ru-msk.vkcloud-storage.ru
Конфигурация хостинга доступна только через S3 API. В личном кабинете этих настроек нет. Для работы нужны три команды:
Команда put-bucket-website полностью перезаписывает текущую конфигурацию. Поэтому безопаснее сначала выгрузить её в JSON, отредактировать файл и затем загрузить обратно.
{ "IndexDocument": { "Suffix": "index.html" }, "ErrorDocument": { "Key": "404.html" }, "RoutingRules": [ { "Condition": { "HttpErrorCodeReturnedEquals": "404", "KeyPrefixEquals": "docs/" }, "Redirect": { "ReplaceKeyPrefixWith": "documentation/", "HttpRedirectCode": "301" } } ] } aws s3api put-bucket-website \ --bucket my-site-bucket \ --website-configuration file://website.json \ --endpoint-url https://hb.ru-msk.vkcloud-storage.ru aws s3api get-bucket-website \ --bucket my-site-bucket \ --endpoint-url https://hb.ru-msk.vkcloud-storage.ru
IndexDocument можно использовать вместе с ErrorDocument и RoutingRules, но нельзя сочетать с RedirectAllRequestsTo. Бакет либо работает как сайт, либо перенаправляет весь трафик на другой хост.
В значении Suffix нельзя использовать символ /.
Сайт будет доступен по адресу:
https://<ИМЯ_БАКЕТА>.hb-website.<ENDPOINT_HOSTNAME>/<PREFIX>
Для Москвы хост — hb.ru-msk.vkcloud-storage.ru, для Казахстана — hb.kz-ast.vkcloud-storage.ru.
Логика индексной страницы у объектного хранилища отличается от привычного веб-сервера.
| Запрос | Что происходит |
| Корень / или адрес без пути | Отдаётся объект с ключом index.html |
| Путь заканчивается на / | Отдаётся <СУФФИКС>index.html, например /images/photos/ → images/photos/index.html |
| Путь без завершающего слеша | Хранилище ищет объект по ключу. Если его нет, отправляет редирект на путь со слешем: /docs/billing → /docs/billing/ |
| Индексная страница не найдена | 404 |
| Индексная страница недоступна | 403 |
Если в имени бакета есть точка, сайт будет доступен только по HTTP, пока для него не выпустят SSL-сертификат. Поэтому вместо www.example.ru лучше использовать имя без точек.

S3-совместимое хранилище с хостингом сайтов, Hotbox/Icebox и бесплатной загрузкой файлов
После настройки бакета нужно загрузить результат сборки. Для S3 сайт должен быть полностью собран в файлы заранее.
Next.js
Статический экспорт включается одной строкой в конфигурации. После output: 'export' команда next build создаёт каталог out с HTML-, CSS- и JS-файлами приложения.
// next.config.js module.exports = { output: 'export', // /me → /me/ и /me.html → /me/index.html trailingSlash: true, }; npm run build # Под капотом next build, отдельная команда экспорта не нужна
В актуальной документации нет отдельной команды next export, хотя поисковая выдача до сих пор часто предлагает устаревшую последовательность next build && next export. Достаточно одного next build.
trailingSlash: true здесь важен: маршрут превращается в папку с index.html. Например,_ /docs/_ соответствует объекту docs/index.html, который объектное хранилище отдаёт без дополнительного редиректа.
Перед переносом приложения на статический экспорт стоит проверить ограничения. В этом режиме не работают:
Серверные компоненты остаются: они выполняются во время сборки, а fetch внутри них вызывается на этапе next build. Клиентская догрузка данных и SPA-поведение тоже сохраняются.
При отключённой оптимизации изображения можно хранить в том же бакете и направить компонент Image на них через кастомный загрузчик.
// next.config.js module.exports = { output: 'export', images: { loader: 'custom', loaderFile: './my-loader.ts', }, };
Hugo
hugo build публикует сайт в каталог public. Его можно сменить флагом --destination или параметром publishDir.
У Hugo есть одна особенность: перед сборкой он не очищает public. Существующие файлы перезаписываются, но удалённые страницы остаются в каталоге. Если страница исчезла из проекта вчера, старый файл всё равно может попасть в бакет и продолжить открываться.
rm -rf public && hugo build aws s3 sync ./public s3://my-site-bucket \ --delete \ --endpoint-url https://hb.ru-msk.vkcloud-storage.ru
Флаг --delete нужен и на стороне бакета: он удаляет объекты, которых больше нет в новой сборке.
При синхронизации не забудьте про --acl public-read. Предопределённый ACL назначается бакету и объектам отдельно. Если загрузить свежую сборку без публичного ACL, сайт будет отвечать 403.
Hugo умеет деплоить в S3-совместимые хранилища встроенной командой hugo deploy. Она синхронизирует public с бакетом, а матчеры позволяют задать заголовки для объектов во время загрузки.
[[deployment.targets]] name = 'production' url = 's3://my-site-bucket?endpoint=https://hb.ru-msk.vkcloud-storage.ru&awssdk=v2&use_path_style=true&disable_https=false' [[deployment.matchers]] pattern = '^.+\.(js|css|svg|ttf)$' cacheControl = 'max-age=31536000, no-transform, public' gzip = true [[deployment.matchers]] pattern = '^.+\.(png|jpg)$' cacheControl = 'max-age=31536000, no-transform, public' gzip = false hugo deploy --target=production
Формат URL с use_path_style=true подходит для S3-совместимых хранилищ, включая MinIO и Ceph. Для команды потребуется версия Hugo с поддержкой deploy: deploy- или extended/deploy-сборка.
После нескольких релизов загрузку лучше перенести в пайплайн: он синхронизирует сборку с бакетом и очищает кэш CDN.
# .github/workflows/deploy.yml name: deploy on: push: branches: [main] jobs: publish: runs-on: ubuntu-latest env: AWS_ACCESS_KEY_ID: ${{ secrets.VK_S3_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.VK_S3_SECRET }} S3_ENDPOINT: https://hb.ru-msk.vkcloud-storage.ru steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 - run: npm ci && npm run build - name: Sync build to bucket run: | aws s3 sync ./out s3://my-site-bucket --delete \ --endpoint-url "$S3_ENDPOINT" - name: Purge CDN cache run: | curl -X POST \ "$CDN_API/projects/$PROJECT_ID/resources/$RESOURCE_ID/purge" \ -H "X-Auth-Token: $CDN_TOKEN" \ -H "Content-Type: application/json" \ -d '{"paths": ["/index.html", "/*.html", "/sitemap.xml"]}' env: CDN_API: ${{ secrets.VK_CDN_API }} CDN_TOKEN: ${{ secrets.VK_CDN_TOKEN }} PROJECT_ID: ${{ secrets.VK_PROJECT_ID }} RESOURCE_ID: ${{ secrets.VK_CDN_RESOURCE_ID }}
Тот же сценарий в GitLab CI:
build: stage: build image: node:20 script: - npm ci && npm run build artifacts: paths: [out] deploy: stage: deploy image: python:3.12-slim needs: [build] rules: - if: $CI_COMMIT_BRANCH == "main" script: - pip install --quiet awscli - aws s3 sync ./out s3://my-site-bucket --delete --endpoint-url "$S3_ENDPOINT" - > curl -X POST "$CDN_API/projects/$PROJECT_ID/resources/$RESOURCE_ID/purge" -H "X-Auth-Token: $CDN_TOKEN" -H "Content-Type: application/json" -d '{"paths": ["/*.html"]}'
Идентификаторы проекта и CDN-ресурса находятся в личном кабинете на странице ресурса. API включается в настройках проекта и требует двухфакторной аутентификации.
В секретах CI хранятся учётные данные. Keystone-токен лучше запрашивать прямо в job перед очисткой кэша: он действует один час.
Для очистки используется метод:
POST /projects/{project_id}/resources/{resources_id}/purge
В теле передаётся массив paths. Пустой массив очищает весь кэш и создаёт лишнюю нагрузку на серверы-источники, поэтому пути лучше перечислять явно.
Сайт уже открывается по адресу бакета, но бакет находится в конкретном регионе. Без CDN пользователь из Хабаровска и пользователь из Москвы получают файлы из одной точки. CDN кэширует контент на пограничных серверах и отдаёт его с ближайшего узла.
В сети VK Cloud более 46 точек присутствия в Европе, СНГ, Северной и Южной Америке, более 200 кэширующих серверов, более 700 пиринговых партнёров и ёмкость свыше 12 Тбит/с. В России точки присутствия работают в 16 городах — от Петрозаводска и Пскова до Красноярска и Хабаровска.
CDN можно подключить прямо из интерфейса бакета. CDN-ресурс и SSL-сертификаты при этом создаются автоматически. Ручная настройка через интерфейс CDN нужна, если источник находится не в Object Storage или сертификат нужно добавить вручную.
Порядок действий:
Есть два ограничения:
Единого показателя задержки в документации CDN нет. Для сайта полезнее измерять время до первого байта из регионов, где находится аудитория.
На этом этапе чаще всего путают настройку домена с настройкой сертификата. В связке статического сайта на S3 и собственного домена это разные настройки на разных уровнях.
Домен на бакете
В настройках бакета есть вкладка «Домен». Нажмите «Привязать домен», скопируйте значение вида mybucket.hb.ru-msk.vkcloud-storage.ru, добавьте CNAME-запись у DNS-провайдера, дождитесь распространения DNS и укажите домен в поле.
Распространение обычно занимает 15–20 минут.
Привязать можно только домен третьего уровня и выше: mysite.example.com подойдёт, а корневой example.com — нет. После привязки бакет будет доступен по адресу:
http://<ИМЯ_БАКЕТА>.<ИМЯ_ДОМЕНА>
Привязка домена к бакету сама по себе не включает HTTPS. Сертификат для бакета выпускают по заявке в техническую поддержку: можно выбрать Let's Encrypt или загрузить собственный, но для каждого домена понадобится отдельный сертификат.
Обычно HTTPS настраивают через CDN, где сертификат выпускается автоматически. Если в имени бакета есть точка, без сертификата он и в этой схеме останется доступен только по HTTP.
Сертификат на CDN-ресурсе
У CDN-ресурса есть три варианта шифрования:
| Опция | Что происходит |
| Не использовать | К персональным доменам можно обращаться только по HTTP |
| Let's Encrypt | Значение по умолчанию, бесплатный сертификат. Автопродление включается параметром _ssl_automated_ |
| Свой сертификат | В хранилище сертификатов загружаются публичная часть и приватный ключ в формате PEM |
Let's Encrypt создаётся после того, как станут доступны серверы-источники и распространятся DNS-изменения для CNAME-записей персональных доменов. Обычно это занимает до 30 минут.
Для выпуска сертификата должна быть включена опция «Доступ к контенту конечным пользователям».
Через API настройки выглядят так:
Корпоративный сертификат добавляется в хранилище сертификатов CDN. Нужны имя, публичная часть в PEM и приватный ключ в PEM. У готовой записи можно изменить только имя, поэтому при замене сертификата создаётся новая запись. Напоминание о сроке истечения лучше поставить сразу.
Для домена_ example.com_ последовательность будет такой:
Если после деплоя на сайте остаётся старая версия, проверьте кэш. На раздачу влияют четыре уровня кэширования.
Заголовок приезжает вместе с файлом и задаёт правила для всего, что стоит ниже по цепочке. Hugo может проставлять его при загрузке через deployment.matchers, например:
max-age=31536000, no-transform, public
Такой заголовок удобно использовать для js, css, svg, ttf, png и jpg. При загрузке через AWS CLI тот же результат даёт флаг --cache-control.
Если у источника нет заголовка, CDN использует TTL по умолчанию — 4 дня. Он применяется к ответам с кодами 200, 201, 204, 206, 301, 302, 303, 304, 307 и 308. Остальные ответы не кэшируются.
Собственный TTL ресурса действует уже: только для кодов 200, 206, 301 и 302.
В API за это отвечает блок options.edge_cache_settings с полями:
После истечения TTL пограничный сервер проверяет файл на источнике. Если ETag совпал, файл продолжает отдаваться из кэша. Если нет, CDN кэширует новую версию.
Новая сборка попадёт в раздачу сама, но не раньше истечения TTL.
Кэш браузера настраивается отдельно от CDN. Он использует либо Cache-Control источника, либо собственный TTL. Если заголовка нет, браузер не кэширует ответ.
Отозвать уже выданный браузеру длинный max-age нельзя. Пользователь получит новую версию только после истечения срока.
Очистка бывает полной и выборочной. Полная очистка заставляет CDN-серверы снова запрашивать контент у источников, увеличивает нагрузку и может дестабилизировать сервис. Для больших объёмов лучше очищать только нужные пути.
Выборочная очистка работает по пути или шаблону:
В API пустой массив paths очищает весь кэш. Непустой массив очищает только указанные пути.
Рабочая схема деплоя выглядит так:
Крупные файлы можно заранее загрузить в CDN-кэш через POST .../prefetch, не дожидаясь первого запроса пользователя. Это имеет смысл для файлов больше 200 МБ. Сначала очистите старую версию из кэша, затем запустите предзагрузку.
Стоимость сайта складывается из трёх платных и двух бесплатных пунктов.
| Строка счёта | Тарифицируется | Комментарий |
| Объём хранения в бакете | Да, за ГБ | Зависит от класса хранения: Hotbox или Icebox |
| Исходящий трафик Object Storage | Да, за ГБ | Любое скачивание из бакета, включая обращения виртуальной машины внутри облака по внешнему IP |
| Трафик CDN | Да, отдельной строкой | Входящий и исходящий трафик на кэширующие CDN-серверы |
| Входящий трафик | Нет | Заливка сборки сама по себе денег не стоят |
| Вызовы API | Нет | Любое число вызовов API бесплатны |
Месячный счёт можно представить так:
Счёт = Объём_ГБ × ставка_класса + Исходящий_трафик_S3_ГБ × ставка_трафика_S3 + Трафик_CDN_ГБ × ставка_трафика_CDN
Для оценки заранее важны несколько моментов:
Для типового лендинга статика вместе с изображениями обычно занимает единицы гигабайт и почти не влияет на итоговую сумму. Основные расходы даёт трафик. Оптимизация картинок и шрифтов обычно экономит больше, чем выбор класса хранения.
Да. Бакет с включённым хостингом отдаёт сайт по адресу вида https://<ИМЯ_БАКЕТА>.hb-website.<ENDPOINT_HOSTNAME>/
Свой домен привязывается к бакету через CNAME. По умолчанию сайт на таком домене открывается по http://. Сертификат для бакета можно выпустить через техническую поддержку: подойдёт Let's Encrypt или собственный сертификат, но для каждого домена потребуется отдельный.
HTTPS обычно настраивают через CDN: сертификат выпускается на стороне CDN.
Сначала привяжите домен к бакету: на вкладке «Домен» скопируйте выданное значение, добавьте CNAME-запись у DNS-провайдера и дождитесь распространения DNS. Обычно это занимает 15–20 минут.
Затем подключите CDN из настроек бакета. Укажите домен как FQDN без завершающей точки. Сертификат на CDN настраивается автоматически. Let's Encrypt включён по умолчанию, ничего не стоит и обычно выпускается в течение 30 минут после того, как DNS-записи станут доступны.
CDN отдаёт файлы не из региона, где находится бакет, а с ближайшего пограничного сервера. В сети VK Cloud больше 46 точек присутствия, а суммарная ёмкость сети превышает 12 Тбит/с.
Через CDN также настраивается HTTPS для персонального домена. Сертификат привязывается к CDN-ресурсу, а не к бакету.
В счёте три строки:
Входящий трафик и вызовы API не тарифицируются, поэтому частота деплоев на стоимость не влияет. Исходящим считается любое скачивание из бакета, в том числе обращение виртуальной машины внутри облака через внешний IP.
Актуальные ставки нужно брать из прайс-листа.
Да, если приложение можно собрать в статические файлы. Для Next.js нужно включить output: 'export' и выполнить next build. Готовая сборка появится в каталоге out.
Клиентская навигация и догрузка данных продолжат работать. Серверные компоненты выполняются на этапе сборки.
Статический экспорт не поддерживает инкрементальную регенерацию, Server Actions, cookies, rewrites, redirects, headers, Draft Mode и оптимизацию изображений со стандартным загрузчиком.
Для вложенных маршрутов включите trailingSlash: true. В конфигурации бакета должны быть заданы ErrorDocument и RoutingRules, чтобы прямой переход на внутреннюю страницу не заканчивался ошибкой.
Для статического сайта достаточно собрать файлы, загрузить их в бакет и открыть к ним публичный доступ. S3 будет раздавать HTML, стили, скрипты и медиа, а CDN возьмёт на себя кэширование, доставку контента из ближайших точек и HTTPS на собственном домене.
Next.js в таком сценарии собирается командой next build с output: 'export', Hugo — командой hugo build. Перед сборкой Hugo каталог public нужно очищать, иначе в следующую загрузку могут попасть удалённые файлы.
Перед первым релизом проверьте ACL бакета, его имя и конфигурацию индексной страницы. При загрузке задайте Cache-Control, а в pipeline добавьте выборочную очистку CDN-кэша для HTML и служебных файлов. После этого останется создать бакет, загрузить сборку, включить CDN и направить домен на выданный адрес.
Наши специалисты свяжутся с вами в ближайшее время и ответят на все вопросы.

Будем держать в курсе новостей и облачных трендов




