S3-backed file storage for Frappe with native bench backup / bench restore integration.
Unlike other S3 attachment apps, files uploaded via s3_store are pulled into the standard bench backup --with-files tar (so it's self-contained) and pushed back to S3 after bench restore with a single command — or automatically. No separate scheduled DB pushes, no parallel backup infrastructure.
Compatible with AWS S3, MinIO, and any S3-compatible service.
- Installation
- Configuration
- How uploads work
- How serving works
- Backup with files
- Restore
- Bulk migration of pre-existing local files
- Settings reference
- Architecture notes
- Testing
- Troubleshooting
- Limitations
bench get-app https://github.com/Centura-AG/s3_store
bench --site <site> install-app s3_storeThis pulls in a single Python dependency: boto3. No node modules, no native extensions.
Open S3 Store Settings (singleton, System Manager only) in Desk.
Minimum required for AWS S3:
| Field | Example |
|---|---|
| Enabled | ✓ |
| Region | eu-central-1 |
| AWS Access Key ID | AKIAIOSFODNN7EXAMPLE |
| AWS Secret Access Key | wJalrXUt… |
| Bucket | my-frappe-uploads |
For MinIO / S3-compatible providers, also set:
| Field | Example |
|---|---|
| Endpoint URL | https://minio.example.com |
Saving the settings runs head_bucket against the configured S3 endpoint — if credentials, region, or bucket name are wrong, the save fails with a clear error.
When Enabled = 1, every new File doc routes through the write_file hook:
- The file's bytes are uploaded to S3 under a generated key:
{key_prefix}/{YYYY/MM/DD}/{attached_to_doctype}/{token8}_{sanitized_name} - The File doc's
file_urlis rewritten to the backend serve endpoint:/api/method/s3_store.s3_store.api.serve?key=<encoded> - No local copy is kept.
The token8 is a random 4-byte hex prefix — uploads of the same file.pdf from different forms get distinct keys, so they can't overwrite each other in S3 or on disk during backup staging.
Ignoring specific doctypes — list one DocType per line under Advanced → Ignored Doctypes to keep their attachments on the local filesystem (useful for User profile pictures, for example).
/api/method/s3_store.s3_store.api.serve?key=<encoded> resolves the key to a File doc via an exact-match lookup on file_url, enforces check_permission("read") on that doc, then streams the object's bytes through the backend as a download response (display_content_as: inline).
The S3 object is fetched server-side with get_object and proxied to the client — the bucket stays fully private and credentials never leave the server. Browsers never talk to S3 directly.
Because every read is proxied through a web worker, large files occupy a worker for the duration of the download and are buffered in memory. For very large public assets you may prefer fronting the bucket with a CDN; that is out of scope for this app.
bench --site <site> backup --with-filesBehind the scenes, s3_store monkey-patches BackupGenerator.backup_files so that — only when this site has S3 Store Settings.enabled = 1 and include_in_native_backup = 1 — every S3-hosted file is downloaded into public/files/ or private/files/ before tar runs, and removed after. The patch is a no-op on sites without the app installed.
The resulting tar is self-contained: you can move it to a different host, a developer laptop, or a fresh bucket and restore without losing any uploads.
Before staging, s3_store totals file_size across all S3-backed File records and requires ~110% free on the disk hosting the site. If insufficient, the backup aborts with:
Insufficient disk space to stage S3 files for backup:
need <N> bytes, have <M> bytes free under <site_path>.
Free up space or disable include_in_native_backup in S3 Store Settings.
bench backup --with-files --backup-encryption-key <key> works unchanged. Encryption runs after backup_files() in Frappe core, so the staged S3 contents end up inside the encrypted blob.
A file lock at sites/<site>/.s3_store_backup.lock prevents two backups from staging on top of each other. The second attempt fails fast with: "Another s3_store backup is already running for this site."
BackupGenerator.get_recent_backup normally returns recent *-files.tar if one exists within the --older-than window. That would silently skip new S3 uploads — so s3_store neutralises the files-portion only when S3 is enabled, forcing a fresh stage. The DB shortcut is preserved.
bench --site <site> restore <db.sql.gz> \
--with-public-files <files.tar> \
--with-private-files <private-files.tar>After this, the DB has File records whose file_url already points at S3 (?key=…), and the bytes sit on disk under their staging names ({token8}_{sanitized_name}).
To push them back to S3:
bench --site <site> s3-store-push-localFor each staged file, this uploads to the exact existing key referenced by the File doc — no URL rewriting, no fresh keys. Local copies are deleted after a successful push.
Set Auto-Push Local Files after Migrate in S3 Store Settings, and the push runs automatically as an after_migrate hook — i.e. at the end of every bench migrate, which conventionally follows bench restore. Cautious users leave this off and run the command explicitly.
Use this once, after first installing on a site that already has local attachments:
Via the UI: open S3 Migration Log → New, save. The migration runs as a background job; progress is published to the realtime channel and reflected on the log doc (migrated, failed, errors).
Via API:
bench --site <site> execute s3_store.s3_store.api.start_migrationFor every File row with a /files/... or /private/files/... URL, s3_store:
- Mints a fresh S3 key
- Uploads the bytes
- Rewrites
file_urlto the backend serve endpoint - Deletes the local copy (subject to
delete_local_after_push)
Errors are collected per-file — one bad file doesn't abort the run. The log doc shows status, counts, and the per-file error list.
| Field | Default | Description |
|---|---|---|
enabled |
0 |
Master switch. When off, files go to the local filesystem as usual. |
endpoint_url |
— | S3 endpoint. Blank for AWS; set for MinIO etc. |
region |
us-east-1 |
AWS region. |
aws_access_key_id |
— | Required when enabled. |
aws_secret_access_key |
— | Required when enabled (stored encrypted via Frappe's password field). |
bucket |
— | Required when enabled. |
key_prefix |
— | Optional path prefix (e.g. site name). Objects are stored at {prefix}/{YYYY/MM/DD}/{doctype}/{token}_{name}. |
signed_url_expiry |
3600 |
Reserved for future use. Currently unused — all serving goes through the backend proxy endpoint. |
delete_from_s3 |
1 |
Delete the S3 object when a File doc is trashed. |
include_in_native_backup |
1 |
Stage S3 files locally during bench backup --with-files. |
auto_push_after_migrate |
0 |
After bench migrate (typically right after bench restore), push local files back to S3. |
delete_local_after_push |
1 |
Remove the local copy after uploading to S3. |
ignored_doctypes |
— | Newline-separated DocType names whose attachments stay on the local filesystem. |
bench --site <site> run-tests --app s3_storeThe suite covers:
- Settings validation (creds + connection check)
write_fileand delete-on-trash hookservecontent proxying, missing-key, permission-error paths- Bulk migration: rewrite URL, delete local, error collection, log doc lifecycle
- Post-restore push: re-upload to existing key, error when staged file is missing
- Backup staging: cleanup on success, cleanup on exception, mixed-mode skip
- Patched
BackupGeneratoris a no-op when S3 disabled;get_recent_backupneutralises file tars - URL parsing across virtual-host AWS, path-style MinIO, and the backend serve endpoint
All boto3 calls are mocked — the suite never hits the network.
# Remove any previous attempt before re-creating
docker rm -f s3store-minio 2>/dev/null || true
# Start MinIO (change ports if 9200/9201 are taken)
docker run -d --name s3store-minio \
-p 9200:9000 -p 9201:9001 \
-e MINIO_ROOT_USER=admin \
-e MINIO_ROOT_PASSWORD=adminadmin \
minio/minio server /data --console-address ":9001"
# Create the bucket
docker run --rm --network container:s3store-minio --entrypoint sh minio/mc \
-c "mc alias set local http://localhost:9000 admin adminadmin --api s3v4 \
&& mc mb --ignore-existing local/s3store-bucket"Open S3 Store Settings in Desk:
| Field | Value |
|---|---|
| Enabled | ✓ |
| Endpoint URL | http://localhost:9200 |
| Region | us-east-1 |
| AWS Access Key ID | admin |
| AWS Secret Access Key | adminadmin |
| Bucket | s3store-bucket |
Console is at http://localhost:9201 (admin / adminadmin).
bench --site dev.s3.test install-app s3_store
# upload some files via the UI, then:
bench --site dev.s3.test backup --with-files
# Wipe and restore
bench drop-site dev.s3.test
bench --site dev.s3.test restore <db> \
--with-public-files <files.tar> --with-private-files <priv-files.tar>
bench --site dev.s3.test s3-store-push-localAfter the push, the local public/files/ and private/files/ directories should contain only Frappe's own non-S3 files; the bucket should hold every uploaded attachment under its original key.
S3 connection check failed: An error occurred (403) when calling the HeadBucket operation
Wrong credentials, wrong region, or the IAM user/policy doesn't grant s3:ListBucket on the configured bucket. The same policy needs s3:GetObject, s3:PutObject, s3:DeleteObject on arn:aws:s3:::<bucket>/*.
Another s3_store backup is already running for this site.
A previous backup crashed without releasing the lock. Check for hung tar processes; delete sites/<site>/.s3_store_backup.lock if confirmed stale.
Insufficient disk space to stage S3 files for backup
Total of all S3 file sizes for the site exceeds 90% of free disk space. Either free up space or set include_in_native_backup = 0 in S3 Store Settings (you'll lose the self-contained-tar property).
Backup tar is missing some files
Check the Error Log for "S3 Store backup staging" — individual download failures don't abort the backup, they're logged and skipped. The File doc is preserved so you can investigate.
bench s3-store-push-local reports "staged file missing"
The File doc points at an S3 key, but no local copy was found at the expected staging path. Either (a) the file wasn't included in the backup tar (the source backup was made without --with-files), or (b) it was unpacked under an unexpected name (file_name was renamed in the source). The S3 object, if it exists, is unaffected.
-
Disk space is required on the server for both backup and restore. During backup, every S3-hosted file is downloaded to the server's local disk before
tarruns; during restore, the tar is extracted to local disk beforebench s3-store-push-local(or the auto-push hook) re-uploads everything. In both directions the server must have free disk space roughly equal to the total size of all S3-backed files. If that isn't possible, disableinclude_in_native_backupand manage S3 backups separately. -
bench backup --exclude-doctypes Fileis incompatible: the DB dump won't reference the staged files. There's no clean way to detect this from the patch site. -
External
file_url(e.g. Google Drive thumbnails) is ignored by both the backup-staging path and the post-restore push — those URLs aren't under our control. -
Mixed-mode: if a file exists both locally and on S3, the local copy wins during backup staging. After restore, the file at the staging path is pushed up to S3 with the existing key. This is intentional — local-wins makes recovery scenarios predictable.
-
Patch ordering: if another app monkey-patches
BackupGenerator.backup_filesafters3_store, our patch is lost. Both patches must chain via the saved original to coexist;s3_storedoes so. -
File.file_urlindex: theserveendpoint uses exact-match lookups, so it benefits from an index onfile_url. Frappe doesn't ship one by default; on sites with millions of File rows, consider addingidx_file_file_urlvia a custom DB patch.
MIT