Customer-owned, S3-compatible object storage for public uploaded files. Permissioned/private files always stay on local disk.
Each Joinery instance can be configured with one S3-compatible bucket (AWS S3, Backblaze B2, Cloudflare R2, Wasabi, DigitalOcean Spaces, MinIO, etc.). Public uploads (photos, gallery images, blog images) are asynchronously moved to that bucket; the customer carries the storage cost rather than the platform.
Uploads themselves are unchanged — they always land locally first. A scheduled task pushes public files to the bucket on the next cron tick. Private files are never put in the bucket.
Upload arrives → UploadHandler → File row created (fil_storage_driver='local')
│
▼
Cron tick (every 15 min)
│
▼
CloudStorageSync iterates eligible rows:
- public per is_public()
- fil_storage_driver = 'local'
- fil_sync_failed_count < 5
│
Push original + variants concurrently
Re-check is_public()
├── still public → flip flag to 'cloud',
│ delete local copies
└── went private → undo bucket pushes,
leave row at 'local'The per-row fil_storage_driver flag is the source of truth. A
misconfigured global setting cannot strand existing files because each
row independently records where its bytes live.
<site_template>/<filename> ← original
<site_template>/<size>/<filename> ← variants (thumb, avatar, ...)The <site_template> prefix is derived automatically from the
site_template setting (e.g. joinerytest). Multiple Joinery instances
can safely share one bucket — each gets its own prefix without any
configuration. The prefix is intentionally not configurable: changing it
would orphan every existing object in the bucket.
The bucket must be publicly readable. The customer applies that policy at bucket creation; the platform never tries to set it.
All configured via the admin page at /admin/admin_cloud_storage.
Stored in stg_settings:
| Setting | Required | Notes |
|---|---|---|
cloud_storage_endpoint | yes | Hostname or full URL, e.g. s3.us-west-002.backblazeb2.com. |
cloud_storage_region | yes | us-east-1, us-west-002, etc. Auto-fills on endpoint blur if recognizable. |
cloud_storage_bucket | yes | Bucket name. |
cloud_storage_access_key | yes | API key / access key ID. |
cloud_storage_secret_key | yes | API secret. |
cloud_storage_public_base_url | no | Base URL for public reads. Leave empty unless you have a CDN or custom domain. Auto-derived from endpoint+bucket otherwise. |
cloud_storage_enabled | internal | Flipped by the Save flow when Test Connection passes. |
*.amazonaws.com → virtual-hosted; everything else → path-style.https://{bucket}.s3.{region}.amazonaws.com
- Path-style: https://{endpoint_host}/{bucket}/admin/admin_cloud_storage has a single primary Save button that:
cloud_storage_enabled = true,
activates the CloudStorageSync task with frequency = every_run.CloudStorageReverseSync to pull all bucket-stored files
back. Confirmation dialog shows the count of files and free local
disk space.HeadBucket call. Pass means DNS, TCP/TLS,
region, and credentials all work.<prefix>/_joinery_probe-<rand>.txt, then HEAD it via the configured
public URL. Pass means the bucket accepts writes and the public URL
works. The HEAD response is also inspected for CDN markers (see
"Egress" below).permanent_delete and permission flips will
fail) but the test still counts as passing for read/write.The feature exists to save customers storage cost, but raw-bucket egress can dwarf storage savings (AWS S3 egress is ~4× the per-GB storage cost). Use a CDN.
The recommended pattern: B2 + Cloudflare via the Bandwidth Alliance — free egress between B2 and Cloudflare. Cheapest realistic option for most customers.
Other good options:
*.amazonaws.com,
*.backblazeb2.com, *.wasabisys.com, *.digitaloceanspaces.com).The CloudStorageSync task is the forward migration. When cloud
storage is first enabled, the batch query naturally selects every public
local file and the task drains them across cron ticks until the queue is
empty. There is no separate migration task.
Migration starts on the next regular cron tick (within 15 minutes). To start sooner, click "Run Now" on the Scheduled Tasks admin page.
The task is bounded per run (50 rows or 60 seconds, whichever first).
Failures increment fil_sync_failed_count; after 5 consecutive failures
a row is excluded from the batch query and surfaces in the admin UI as
"stuck." The "Retry" button resets the counter and re-queues the row.
CloudStorageReverseSync is activated only by the "Disable and Pull
Files Back to Local" button. Per-row, three phases:
is_public() per row), commit fil_storage_driver = 'local'.CLOUD_STORAGE_ORPHAN: bucket=<name> keys=<...>; the row is
correctly served locally regardless. Manual cleanup with aws s3 rm
or equivalent.'cloud' rows remain.When a file's is_public() flips, the file moves between local and bucket.
Synchronous from the admin's request, three explicit phases:
'cloud' flag stays
truthful, log CLOUD_STORAGE_PARTIAL_FLIP. If re-PUT also fails,
the row is genuinely broken; log marker is the breadcrumb.Peak local disk during a flip ≈ 2× total file size (temp + restricted-dir copy, briefly during phase 3).
The synchronous path doesn't push to the bucket. The row stays at
'local'; the next sync task tick picks it up. Avoids blocking the
user's request on bucket I/O.
File::get_url($size_key, $format) dispatches on the row's flag:
fil_storage_driver = 'local' — existing /uploads/... URL,
served by the fast path or auth route.fil_storage_driver = 'cloud' — driver->url(<remote_key>), the
public CDN/bucket URL. Browser hits the bucket directly; PHP is not
in the loop.Pre-migration /uploads/<filename> URLs (in sent emails, search index
caches, RSS feeds, embedded HTML) keep working: serve.php's /uploads/*
route checks fil_storage_driver and 302-redirects cloud rows to the
bucket URL with Cache-Control: public, max-age=86400. After the first
hit the browser caches the redirect; subsequent hits skip PHP entirely.
s3.<region>.backblazeb2.com (region matches the
bucket's region: us-west-002, us-west-004, etc.).images.example.com) to the bucket
hostname.
- In the Joinery admin, set cloud_storage_public_base_url to
https://images.example.com.s3:GetObject policy you'll add. {
"Version": "2012-10-17",
"Statement": [{
"Sid": "PublicReadGetObject",
"Effect": "Allow",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::YOUR-BUCKET/*"
}]
}s3.<region>.amazonaws.com. Region matches the bucket
region.<account-id>.r2.cloudflarestorage.com.auto.cloud_storage_public_base_url should be the public custom domain
you attached.| Provider | Status | Notes |
|---|---|---|
| Backblaze B2 (S3 API) | Verified | Path-style endpoint. Cloudflare Bandwidth Alliance is the cheapest realistic option. |
| AWS S3 | Verified | Virtual-hosted-style preferred. Reference implementation. |
| Cloudflare R2 | Should work, unverified | Free egress. |
| Wasabi | Should work, unverified | Free egress up to monthly storage allowance. |
| DigitalOcean Spaces | Should work, unverified | Includes a CDN option. |
| MinIO (self-hosted) | Should work, unverified | Path-style. Useful for development. |
| Mode | Behavior | Recovery |
|---|---|---|
| Sync push fails | fil_sync_failed_count increments; next cron tick retries. After 5 failures the row is excluded and surfaces as "stuck". | Click Retry on the stuck-files list. |
| Credentials become invalid | Driver health-check goes red; sync task fails every row. New uploads keep landing locally. | Save again with fixed creds. |
| Bucket runs out of quota / billing failure | Sync task fails; uploads continue locally. | Resolve at the provider; sync resumes. |
permanent_delete bucket-delete fails | Logged as CLOUD_STORAGE_ORPHAN; row is still deleted. | Manual cleanup via aws s3 rm or equivalent. |
| Public→private flip phase 3 fails | Logged as CLOUD_STORAGE_PARTIAL_FLIP. | Manual recovery: flip the row to 'local' and re-upload. |
| File becomes private during async push | Detected by re-check after PUTs; just-pushed objects deleted; row stays local. | Automatic. |
| File | Role |
|---|---|
includes/cloud_storage/CloudStorageDriver.php | Interface (put/get/delete/url/ping). |
includes/cloud_storage/CloudStorageS3Driver.php | Sole implementation. Handles AWS, B2, R2, Wasabi, etc. |
includes/cloud_storage/CloudStorageDriverFactory.php | default() returns configured driver or null. fromOptions() builds from explicit settings (used by Test Connection before persisting). |
data/files_class.php | Cloud-aware methods: get_url(), permanent_delete(), delete_resized(), resize(), move_to_correct_directory() (incl. three-phase pull-back). |
tasks/CloudStorageSync.php | Forward sync task (also serves as one-time forward migration). |
tasks/CloudStorageReverseSync.php | Pull-back task; self-deactivates when no 'cloud' rows remain. |
adm/admin_cloud_storage.php | Admin UI. Save = test + persist + activate. |
adm/logic/admin_cloud_storage_logic.php | Save/Pause/Disable-and-Pull/Retry handlers; Test Connection diagnostic; health-status query. |
serve.php | /uploads/* route extended to 302-redirect cloud rows. |
includes/UploadHandler.php | get_unique_filename() consults fil_files so dedup works after locals are deleted. |
utils/process_scheduled_tasks.php | Per-task advisory locking (prereq for the sync task — prevents tick-overlap races). |