Storage Hub — S3 Integration
The Storage Hub lets you route attachment storage to a cloud object store instead of the local filesystem. Once configured, all new file uploads for an attachment partition are transparently written to S3 (or any S3-compatible service). Existing local-filesystem attachments continue to work side-by-side with no data migration required.
How it works
Open Saasframe uses a pluggable storage driver architecture. Each attachment partition declares which driver to use (local by default). When you configure S3 credentials and switch a partition's driver, all new uploads for that partition go to S3 while reads from older local-filesystem files continue to work through the local driver.
The integration also exposes standalone S3 API endpoints that let you upload, download, delete, and pre-sign URLs for files entirely outside the attachments module — useful for custom integrations or workflows that need direct S3 access.
Prerequisites
- The
storage_s3module must be enabled. SetSF_ENABLE_STORAGE_S3=truein your environment and restart the app. - An AWS account with an S3 bucket, or a compatible service such as DigitalOcean Spaces, MinIO, Cloudflare R2, or Backblaze B2.
- The
storage_providers.managefeature enabled for your role (granted to superadmin and admin by default).
Enable the module
Add the environment variable to your deployment before starting the app:
SF_ENABLE_STORAGE_S3=true
After the first start with this flag set, the S3 integration card appears in External Systems → Integrations.
Step 1 — Enter S3 credentials
- Navigate to External Systems → Integrations and click Configure on the S3 Object Storage card.
- Open the Credentials tab and fill in the connection details.

| Field | Required | Description |
|---|---|---|
| Access Key ID | Yes | AWS access key ID (or equivalent for your provider) |
| Secret Access Key | Yes | AWS secret access key |
| Region | Yes | e.g. us-east-1, eu-west-1, nyc3 for DigitalOcean |
| Bucket | Yes | Name of the pre-created bucket |
| Endpoint URL | No | Custom endpoint for non-AWS providers (see table below) |
| Force Path Style | No | Required for MinIO and some self-hosted S3 services |
- Click Save Credentials.
Provider endpoint reference
| Provider | Endpoint URL | Force Path Style |
|---|---|---|
| AWS S3 | (leave blank) | No |
| DigitalOcean Spaces | https://<region>.digitaloceanspaces.com | No |
| MinIO (self-hosted) | http://<host>:<port> | Yes |
| Cloudflare R2 | https://<account-id>.r2.cloudflarestorage.com | No |
| Backblaze B2 | https://s3.<region>.backblazeb2.com | No |
Step 2 — Verify the connection
After saving, open the Health tab and click Check Health to confirm the credentials are valid and the bucket is reachable.

If the check fails, review the error message. Common causes: wrong region, bucket does not exist, or missing s3:PutObject / s3:GetObject permissions on the IAM policy.
Step 3 — Configure an attachment partition to use S3
- Open Settings → Attachments → Partitions.
- Click Edit on the partition you want to move to S3 (or create a new partition).
- In the Storage Driver dropdown, select Amazon S3.
- If you don't see (or can't select) Amazon S3, make sure
SF_ENABLE_STORAGE_S3=trueis set and the app was restarted.
- If you don't see (or can't select) Amazon S3, make sure
- Fill in the S3 settings for this partition.
- The Bucket/Region/Endpoint here are the ones used by the attachments S3 driver for this partition.
- In most setups you should keep them consistent with the S3 Integration credentials to avoid confusion, but they are applied separately.
- Click Save.

All new files uploaded to this partition from this point forward are stored in S3. The key structure used inside the bucket is:
<partition-code>/org_<orgId>/tenant_<tenantId>/<timestamp>_<uuid>_<filename>
Existing files already uploaded to the local filesystem remain accessible and are served through the local driver. You do not need to migrate them unless you want to.
Environment-based preconfiguration
If your deployment already has S3 credentials at boot time, you can skip the UI form by setting env vars:
SF_ENABLE_STORAGE_S3=true
SF_INTEGRATION_STORAGE_S3_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE
SF_INTEGRATION_STORAGE_S3_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
SF_INTEGRATION_STORAGE_S3_REGION=us-east-1
SF_INTEGRATION_STORAGE_S3_BUCKET=my-saasframe-bucket
# Optional:
# SF_INTEGRATION_STORAGE_S3_SESSION_TOKEN=
# SF_INTEGRATION_STORAGE_S3_ENDPOINT=https://nyc3.digitaloceanspaces.com
# SF_INTEGRATION_STORAGE_S3_FORCE_PATH_STYLE=true
# SF_INTEGRATION_STORAGE_S3_FORCE_PRECONFIGURE=false
SF_ENABLE_STORAGE_S3=true is the module-enablement gate: without it the storage_s3 module is not registered in enabledModules and the rest of the SF_INTEGRATION_STORAGE_S3_* block is ignored. The two layers are intentionally separate so you can preview the integration card in the UI without preconfiguring credentials.
On new tenant setup the module reads SF_INTEGRATION_STORAGE_S3_* and pre-populates the Integration Marketplace credentials so no manual configuration step is needed. Setup failures (for example a partial env block) are surfaced through the Integrations → Logs UI under the storage_s3 filter, so operators can see them alongside other integration activity.
Preconfigure existing tenants from the CLI
onTenantCreated only runs once per tenant. If you upgrade an existing deployment after setting the SF_INTEGRATION_STORAGE_S3_* block, rerun the provider CLI to apply the preset to a tenant that was provisioned before the variables were available:
yarn saasframe storage_s3 configure-from-env --tenant <tenantId> --org <organizationId>
Behavior:
- No env vars set → the command prints
Skipped: No S3 env preset was found.and exits0. Safe to call from a startup hook. - Required vars partially set → exits
1withIncomplete S3 env presetso CI/automation can detect misconfiguration. - Tenant already has stored credentials → prints
Skipped: S3 credentials already exist.and exits0unless you pass--forceor setSF_INTEGRATION_STORAGE_S3_FORCE_PRECONFIGURE=true. - Full env + (no existing creds OR
--force) → writes the credentials and adds an entry to the integration logs.
Use --force when you want to roll keys without going through the Integrations UI:
yarn saasframe storage_s3 configure-from-env \
--tenant <tenantId> \
--org <organizationId> \
--force
Apply to every tenant from a deploy hook (--all-tenants)
For unattended deploys — Dokploy, Coolify, Kamal, plain docker run, etc. — you usually do not want to track tenant and organization UUIDs in your deploy configuration. Use --all-tenants instead: the CLI iterates every active organization in the database and applies the preset to each (tenantId, organizationId) pair.
yarn saasframe storage_s3 configure-from-env --all-tenants
Use it as a post-deploy / release hook in your platform (e.g. a Dokploy "deploy command", a Kamal accessory post-deploy, a Coolify "after deployment" step). On every redeploy, the command:
- Reads the
SF_INTEGRATION_STORAGE_S3_*block from the process environment once. - Enumerates every active organization (excludes soft-deleted ones).
- Calls the same
applyS3EnvPresetper-scope, with the same per-tenant skip /--forcesemantics — already-configured tenants are skipped automatically, fresh tenants get the preset. - Prints a summary line at the end (configured / skipped / errored counts).
Exit code semantics:
- All scopes succeed or skip → exit
0. Safe to wire into every release hook. - Env preset is incomplete (a required
SF_INTEGRATION_STORAGE_S3_*var is missing) → every scope errors → exit1. CI/automation can detect misconfiguration without parsing stdout. - At least one scope errors while others succeed → exit
1. The summary line reports the breakdown. - No active organizations exist yet (fresh DB, before
saasframe init) → exit0with a "Nothing to do" message.
--all-tenants cannot be combined with --tenant / --org. Pick one mode per invocation.
Local development with LocalStack
For local development and integration testing you can run a local S3 stub using LocalStack. Start it alongside the other infrastructure services by enabling the storage-s3 compose profile:
docker compose --profile storage-s3 up -d
Then configure your local .env:
SF_ENABLE_STORAGE_S3=true
SF_INTEGRATION_STORAGE_S3_ACCESS_KEY_ID=test
SF_INTEGRATION_STORAGE_S3_SECRET_ACCESS_KEY=test
SF_INTEGRATION_STORAGE_S3_REGION=us-east-1
SF_INTEGRATION_STORAGE_S3_BUCKET=saasframe-dev
SF_INTEGRATION_STORAGE_S3_ENDPOINT=http://localhost:4566
SF_INTEGRATION_STORAGE_S3_FORCE_PATH_STYLE=true
Create the test bucket before the first run:
aws --endpoint-url=http://localhost:4566 s3api create-bucket --bucket saasframe-dev
LocalStack is not included in the default docker compose up. It only starts when the storage-s3 profile is active to keep the default dev loop lightweight.
Standalone S3 API
The storage_s3 module also exposes API endpoints for direct S3 file operations without going through the attachments module. All routes require authentication and the storage_providers.manage feature.
All keys passed to these endpoints must be scoped to the authenticated tenant's org_<orgId>/tenant_<tenantId>/ namespace.
| Method | Path | Description |
|---|---|---|
POST | /api/storage-providers/s3/upload | Upload a file (multipart/form-data) |
GET | /api/storage-providers/s3/download?key=… | Download a file by S3 key |
POST | /api/storage-providers/s3/signed-url | Generate a pre-signed upload or download URL |
DELETE | /api/storage-providers/s3/delete | Delete a file by S3 key |
GET | /api/storage-providers/s3/list?prefix=… | List files by prefix (auto-scoped to tenant) |
Upload example
curl -X POST https://your-app/api/storage-providers/s3/upload \
-H "Authorization: Bearer <token>" \
-F "file=@/path/to/document.pdf"
Response:
{
"key": "uploads/org_<orgId>/tenant_<tenantId>/1714300000000_abc123_document.pdf",
"bucket": "my-saasframe-bucket",
"size": 104523,
"contentType": "application/pdf"
}
Pre-signed URL example
curl -X POST https://your-app/api/storage-providers/s3/signed-url \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"key":"uploads/org_x/tenant_y/file.pdf","operation":"download","expiresIn":3600}'
Response:
{
"url": "https://my-bucket.s3.amazonaws.com/uploads/...?X-Amz-Signature=...",
"expiresAt": "2026-04-28T14:00:00.000Z"
}
Access control
| Feature | Grants |
|---|---|
storage_providers.manage | Configure integration credentials, use standalone S3 API |
The storage_providers.manage feature is granted to superadmin and admin roles by default. No additional role configuration is needed for standard setups.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
Upload fails with NoSuchBucket | Bucket does not exist in the specified region | Create the bucket or correct the region/endpoint |
| Upload returns 403 from S3 | IAM policy missing s3:PutObject | Add s3:GetObject, s3:PutObject, s3:DeleteObject, s3:ListBucket to the policy |
MinIO returns InvalidAccessKeyId | forcePathStyle not set | Enable Force Path Style in the credentials form |
| Attachment served as 404 after switching partition driver | File was uploaded before driver switch | The file is still on local disk — switch the partition back to local, or re-upload |
S3 integration is not configured error | SF_ENABLE_STORAGE_S3 not set or credentials not saved | Verify env var and re-save credentials via Integrations |