S3, R2 and MinIO

The s3 adapter uploads the build to a bucket with the S3 API. It works with AWS and with S3-compatible services through endpoint. Create the bucket first; Kilim does not create buckets or change their settings.

import { defineConfig } from "@kilim-dev/core";
import { s3 } from "@kilim-dev/core/adapters";

export default defineConfig({
  url: "https://www.example.com",
  deploy: s3({ bucket: "my-site", region: "eu-central-1" }),
});

For Cloudflare R2, use the account's S3 address and region auto; for MinIO, the server address:

deploy: s3({
  bucket: "my-site",
  region: "auto",
  endpoint: "https://<account-id>.r2.cloudflarestorage.com",
}),
  • Credentials come only from AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY and, for temporary credentials, AWS_SESSION_TOKEN. The key needs to list, read, write and delete objects in the bucket (s3:ListBucket, s3:GetObject, s3:PutObject, s3:DeleteObject). For R2, create an R2 API token and use its S3 access key and secret. Never put them in the config.
  • prefix puts the site in a folder of the bucket, e.g. "blog/"; by default it goes to the root.
  • Without endpoint, requests go to https://<bucket>.s3.<region>.amazonaws.com. With endpoint the bucket is put in the path (<endpoint>/<bucket>/...), which MinIO and R2 expect; bucket names with dots use the path too. forcePathStyle: true | false overrides both.
  • Every deploy uploads _kilim/deploy.json last, the list of the site's files and their MD5 hashes. The next kilim deploy reads it and uploads only new and changed files. When the bucket has none (the first deploy, or a bucket filled by another tool), Kilim lists the objects under prefix and compares their ETags; files uploaded in several parts always count as changed.
  • Files the build no longer has are deleted, after the uploads. Without a manifest that means every object under prefix that is not in the build, so run kilim deploy --dry-run first on a bucket with other files, or set deleteRemoved: false to keep old files. Kept files stay in the manifest, so setting deleteRemoved: true later deletes them.
  • Pages are uploaded after stylesheets and images, so a visitor never gets a page whose assets are missing. Each file gets its Content-Type (text types with charset=utf-8) and a Cache-Control: a year and immutable for the hashed files in _kilim/css/ and _kilim/img/, max-age=0, must-revalidate for everything else.
  • S3 does not read _headers and _redirects, so they are not uploaded. Redirects from aliases and redirects.txt still work through the redirect pages Kilim writes for them. For the security headers and CSP in _headers, add them where the site is served: a CloudFront response headers policy, or R2 custom domain rules.
  • To serve the bucket as a website, turn on static website hosting with index.html as the index document and 404.html as the error document, or put CloudFront (or an R2 custom domain) in front of it.

kilim doctor shows the address requests go to, whether the credentials are set, and warns about region: "auto" without endpoint or an endpoint that uses plain http.

# .github/workflows/s3.yml
name: S3
on:
  push:
    branches: [main]
permissions:
  contents: read
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - run: npx kilim deploy
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}