Skip to main content

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_s3 module must be enabled. Set SF_ENABLE_STORAGE_S3=true in 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.manage feature 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

  1. Navigate to External Systems → Integrations and click Configure on the S3 Object Storage card.
  2. Open the Credentials tab and fill in the connection details.

S3 integration credentials form showing bucket, region, and access key fields

FieldRequiredDescription
Access Key IDYesAWS access key ID (or equivalent for your provider)
Secret Access KeyYesAWS secret access key
RegionYese.g. us-east-1, eu-west-1, nyc3 for DigitalOcean
BucketYesName of the pre-created bucket
Endpoint URLNoCustom endpoint for non-AWS providers (see table below)
Force Path StyleNoRequired for MinIO and some self-hosted S3 services
  1. Click Save Credentials.

Provider endpoint reference

ProviderEndpoint URLForce Path Style
AWS S3(leave blank)No
DigitalOcean Spaceshttps://<region>.digitaloceanspaces.comNo
MinIO (self-hosted)http://<host>:<port>Yes
Cloudflare R2https://<account-id>.r2.cloudflarestorage.comNo
Backblaze B2https://s3.<region>.backblazeb2.comNo

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.

S3 integration health tab showing a passing health check

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

  1. Open Settings → Attachments → Partitions.
  2. Click Edit on the partition you want to move to S3 (or create a new partition).
  3. 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=true is set and the app was restarted.
  4. 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.
  5. Click Save.

Attachment partition settings with Storage Driver dropdown set to S3

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>
info

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 exits 0. Safe to call from a startup hook.
  • Required vars partially set → exits 1 with Incomplete S3 env preset so CI/automation can detect misconfiguration.
  • Tenant already has stored credentials → prints Skipped: S3 credentials already exist. and exits 0 unless you pass --force or set SF_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 applyS3EnvPreset per-scope, with the same per-tenant skip / --force semantics — 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 → exit 1. 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) → exit 0 with 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
note

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.

MethodPathDescription
POST/api/storage-providers/s3/uploadUpload a file (multipart/form-data)
GET/api/storage-providers/s3/download?key=…Download a file by S3 key
POST/api/storage-providers/s3/signed-urlGenerate a pre-signed upload or download URL
DELETE/api/storage-providers/s3/deleteDelete 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

FeatureGrants
storage_providers.manageConfigure 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

SymptomLikely causeFix
Upload fails with NoSuchBucketBucket does not exist in the specified regionCreate the bucket or correct the region/endpoint
Upload returns 403 from S3IAM policy missing s3:PutObjectAdd s3:GetObject, s3:PutObject, s3:DeleteObject, s3:ListBucket to the policy
MinIO returns InvalidAccessKeyIdforcePathStyle not setEnable Force Path Style in the credentials form
Attachment served as 404 after switching partition driverFile was uploaded before driver switchThe file is still on local disk — switch the partition back to local, or re-upload
S3 integration is not configured errorSF_ENABLE_STORAGE_S3 not set or credentials not savedVerify env var and re-save credentials via Integrations