Search by

joredierckx / kirby-s3-sync

Jorckx

πŸ”„ Syncs Kirby files to s3.

Package info

github.com/Jorckx/kirby-s3-sync

Homepage

Issues

Type:kirby-plugin

pkg:composer/joredierckx/kirby-s3-sync

Statistics

Installs: 82

Dependents: 0

Suggesters: 0

Stars: 4

1.2.1 2026-10-02 15:19 UTC

README

Kirby ThumbHash Banner

Kirby S3 Sync

Note

Disclaimer: Although core safeguards are in place, use it at your own risk. We strongly recommend creating backups before use. Since version 1.0.8 it supports local development, you can use it to test before deploying to production.

Sync Kirby CMS files to S3 bucket/storage automatically, offloading local/server disk usage and serving assets through a CDN.

This plugin was originally inteded to work with Cloudflare's r2 storage, but since this is s3 compatible it (should) work with any S3-compatible storage.

Files uploaded through the Panel are pushed to your bucket, verified, and then replaced locally with a tiny placeholder β€” so your Kirby installation stays lightweight while the real files live on S3. file::url, file::version, and file::dimensions are transparently rerouted to the CDN, so templates and the Panel keep working without any changes to your existing code.

If this plugin saves you time, consider supporting

Features

  • ⬆️ Automatic upload on file create/replace via Kirby hooks
  • ☁️ Works with any S3-compatible provider (R2, DigitalOcean Spaces, etc.) β€” CDN-specific metadata is skipped automatically if s3.cdn isn't configured
  • πŸ›Ÿ Local file is only swapped for a placeholder after the upload is verified β€” nothing is lost on a failed upload
  • πŸ”— CDN-aware file::url, file::version, and file::dimensions components β€” templates don't need to change
  • πŸ“ Image dimensions read locally via getimagesize(), so width/height are always correct regardless of CDN processing delays
  • πŸ“ Optional Cloudflare Images JSON metadata stored alongside each file
  • πŸ—„οΈ Soft-delete: files are archived (_archive/...) in the bucket before deletion, not destroyed outright
  • πŸ”Œ REST API route for uploading from custom Panel UI or external tools
  • 🚚 CLI commands: s3:migrate and s3:restore (only works when getkirby/cli is installed)
    • s3:migrate (since v1.2.0) command for bulk migration of existing files and for backfilling metadata after upgrades.
    • s3:restore (since v1.2.1) command for reverse-migrating files from the s3 bucket back to the kirby's filesystem.

Requirements

  • Kirby 3.6+ (for automatic Composer autoloading of plugins)
  • PHP 8.1+
  • An S3-compatible bucket (Cloudflare R2, DigitalOcean Spaces, AWS S3, etc.)

Installation

Via Composer (recommended)

composer require joredierckx/kirby-s3-sync

Configuration

Add the following to your site's config.php. Config options can be nested under an s3 key:

return [
    's3' => [
        'active'   => true,                                    // recommended: true in production only, false in local/staging
        'key'      => env('S3_KEY'),                          // never hardcode β€” see .env below
        'secret'   => env('S3_SECRET'),                        // never hardcode β€” see .env below
        'bucket'   => 'your-bucket-name',
        'region'   => 'auto',                                  // R2 always uses 'auto'
        'endpoint' => 'https://<account-id>.r2.cloudflarestorage.com',
        'sitename' => 'your-site-slug',                        // must be unique per site sharing a bucket β€” avoid '.' since it's used as a subdomain
        'cdn'      => 'https://cdn.yourdomain.com',             // optional β€” omit for non-Cloudflare providers
    ],
];

key and secret are your R2/S3 access credentials. These must never be hardcoded in config.php or committed to version control. Store the actual values in your site's .env file instead (which should be gitignored), and reference them via env() as shown above:

S3_KEY=your-access-key-id
S3_SECRET=your-secret-access-key

Kirby supports environment-specific config files (e.g. config/config.local.php), so s3.active can be set per environment β€” leaving it false on local/staging keeps development files on local disk (simpler to inspect and reset) and reserves bucket writes for production only.

Option reference

Option Required Description
s3.active Yes Master switch. When false, all hooks and API routes no-op and local files behave exactly as vanilla Kirby. Recommended: true only in production β€” keep it false on local/staging environments so development work doesn't write to your live bucket.
s3.key Yes Access key ID. Set via env('S3_KEY'), with the real value stored in .env β€” never hardcode this.
s3.secret Yes Secret access key. Set via env('S3_SECRET'), with the real value stored in .env β€” never hardcode this.
s3.bucket Yes Name of your S3-compatible bucket.
s3.region Yes Bucket region. Use auto for R2.
s3.endpoint Yes S3-compatible API endpoint URL.
s3.sitename Yes Namespace prefix for object keys (sitename/page-id/assets/...). Must be unique per site if multiple sites share one bucket, or files can collide. Avoid . in the value since it's used as a subdomain.
s3.cdn No Public CDN base URL. When set, enables Cloudflare Image Resizing URLs and JSON metadata fetching. Leave unset for providers without an equivalent (e.g. plain DigitalOcean Spaces).
s3.json No Default false. When true (and s3.cdn is set), Cloudflare's image JSON is fetched after the response is sent and stored as s3_json.
s3.localhost No Default false. On a local host (localhost, *.test, *.local, *.ddev.site, loopback IP) the sync runs as a dry run: nothing is uploaded or changed, and what would happen is logged. Set to true to really sync from localhost.

Local development

With s3.active set to true and s3.localhost set to false (default), uploads, replaces and deletes on a local host are only logged to site/logs/s3-sync.log:

[2026-09-29 17:10:02] INFO create:after dry run {"file":"foo.jpg","page":"projects/x","bucket":"…","key":"…","s3_width":2400,"s3_height":1600,"cdn_url":"…","would":[…]}

Local files and content stay untouched, so the Panel keeps working with plain local files. Run tail -f site/logs/s3-sync.log to follow along.

On a local host every step is logged (also with s3.localhost on). In production only errors are logged, to s3-sync.log and PHP's error_log.

⚠ With s3.localhost on, local uploads are real: they go into the bucket and the local original is replaced by the 1Γ—1 placeholder. Use a separate s3.sitename locally (e.g. mysite-local) so you never overwrite production objects.

How it works

On file create / replace

  1. File is uploaded to the bucket via putObject.
  2. Upload is verified with a doesObjectExist check.
  3. Image dimensions are read locally with getimagesize() β€” this doesn't depend on the CDN having finished processing the file yet.
  4. If s3.cdn is configured, Cloudflare's image-info JSON endpoint is fetched as supplementary metadata (file size, format, etc.). This step is skipped entirely on non-Cloudflare providers.
  5. s3_key, s3_json, s3_width, and s3_height are saved to the file's content file.
  6. Only after all of the above succeeds, the local file is replaced with a 1Γ—1px placeholder.
  7. Published media copies and thumbnails are removed (on existing installs, re-run the migrate script, or delete media/pages/. Deleting it is safe because Kirby regenerates it on demand, and synced files are served from the CDN anyway.).

If any step fails, the error is logged and the local file is left completely untouched β€” nothing is deleted or replaced on a failed upload.

On file delete

The object is copied to an _archive/ prefix in the same bucket before the original key is deleted β€” a soft-delete safety net. You can periodically clear out _archive/ manually (or set up a cron-job) once you're confident nothing needs recovering.

Serving files

Three of Kirby's core components are overridden:

  • file::url β€” returns the CDN/bucket URL when s3_key is set, otherwise falls back to Kirby's native local URL.
  • file::dimensions β€” returns the stored s3_width/s3_height when available, avoiding a filesystem read on a file that's now just a placeholder.
  • file::version β€” builds a Cloudflare Image Resizing URL (/cdn-cgi/image/...) from width, height, quality, fit, and crop options passed to Kirby's usual $file->resize() / $file->crop() calls.

Existing templates using $file->url(), $file->resize(), $file->crop(), etc. don't need any changes.

Blueprint fields

The plugin ships a reusable field group (blueprints/fields/s3meta.yml) that can be included in any file blueprint where you want these fields visible in the Panel β€” all are managed automatically and don't need to be editable:

fields:
  s3meta:
    extends: fields/s3meta

The fields in the group are:

  • s3_key β€” the S3 key of the file (read-only).
  • s3_width β€” the width of the file (read-only).
  • s3_height β€” the height of the file (read-only).

s3_json is intentionally left out of the group by default since it's raw metadata, not something editors need to see β€” but it's stored in the content file and can be added to the group the same way if useful for debugging.

REST API

A route is registered for programmatic uploads (e.g. from a custom Panel UI):

POST /api/s3-upload/{pageId}/{filename}

{pageId} should have any / characters replaced with +. Returns {"status": "ok"} on success, or {"status": "error", "message": "..."} on failure. Respects s3.active β€” returns an error if sync is disabled.

CLI scripts

scripts/ folder (legacy - removed since v1.2.0)

Warning

legacy - the scripts-folder got replaced by the s3:migrate command since v1.2.0

Custom standalone scripts /scripts/*-script.php that were used before v1.2.0. These scripts are replaced by the commands which offer the same functionality with a more modern interface (kirby/cli).

Commands: s3:migrate & s3:restore

s3:migrate runs the same migration, but with a more modern interface (kirby/cli). It can also be triggered from the Panel with Janitor:

s3:restore is the reverse migration command, which restores files from the S3 bucket back to the local filesystem (since v1.2.1).

vendor/bin/kirby s3:migrate --dry-run            # whole site, only report
vendor/bin/kirby s3:migrate --page projects/x    # one page
vendor/bin/kirby s3:migrate                      # whole site, live

Panel buttons (requires bnomei/kirby-janitor), e.g. in site/blueprints/site.yml:

Note

You need to install the CLI (getkirby/cli) with composer into your project and not use the global version. Since Janitor depends on the CLI to be available, installing only the janitor plugin via submodules or via ZIP is NOT supported.

s3MigrateDry:
  type: janitor
  command: 's3:migrate --dry-run'
  label: S3 migration β€” dry run
  icon: search
  progress: Checking…
s3Migrate:
  type: janitor
  command: 's3:migrate'
  label: S3 migration β€” run
  icon: upload
  confirm: Upload all local files to S3 and replace them with placeholders?
  progress: Migrating…
  • On site.yml the button migrates the whole site; on a page blueprint only that page (Janitor adds --page).
  • Only admins can run it from the Panel.
  • On a local host it's always a dry run unless s3.localhost is true (same rule as the hooks).
  • The button shows a summary (Done Β· skipped Β· errors); the full per-file list is in the browser console and in site/logs/s3-sync.log.
  • New uploads reuse the normal upload flow (local width/height, placeholder, media cleanup). With s3.json on, the CDN JSON is fetched after the response is sent.

update-s3-meta.php

Backfills s3_width / s3_height (and s3_json) on files that were migrated before those fields existed, or after a plugin upgrade that adds new metadata fields.

php scripts/update-s3-meta.php --dry-run
php scripts/update-s3-meta.php
  • Reuses a file's already-stored s3_json when present, only hitting the CDN if it's missing
  • Skips files that already have both s3_width and s3_height populated
  • Skips non-image files and files not yet on the bucket

Provider compatibility

This plugin works with any S3-compatible endpoint. Cloudflare-specific behavior (the JSON metadata fetch and Image Resizing URLs in file::version) is only active when s3.cdn is set β€” leave it unset when using a provider without an equivalent feature, and the plugin falls back to plain bucket URLs and locally-read dimensions.

Safety notes

  • Nothing is ever deleted locally until the corresponding upload is verified in the bucket.
  • Nothing is ever deleted from the bucket without first being archived under _archive/.
  • All hooks and the API route respect s3.active β€” set it to false to fully disable the plugin's behavior without uninstalling it.

Support

If this project saves you time, consider supporting it:

Buy Me A Coffee

License

MIT Β© Jore Dierckx