Skip to main content

Storage Architecture

Forja supports two storage backends for media files: local filesystem (default) and S3-compatible object storage. Both implement the same StorageBackend trait, making them interchangeable via configuration.

StorageBackend Trait

All storage operations go through a common interface:

#[async_trait]
pub trait StorageBackend: Send + Sync {
/// Store file data, returning the public URL
async fn store(&self, path: &str, data: &[u8], content_type: &str) -> Result<String, ApiError>;

/// Delete the file at the given path
async fn delete(&self, path: &str) -> Result<(), ApiError>;

/// Check if a file exists
async fn exists(&self, path: &str) -> Result<bool, ApiError>;

/// Get the public URL for a storage path
fn public_url(&self, path: &str) -> String;

/// Health check with disk/bucket info
async fn health_check(&self) -> StorageHealthInfo;
}

The storage backend is initialized at startup and stored in AppState as Arc<dyn StorageBackend>, making it available to all handlers.

Local Filesystem Storage

The default storage provider writes files to a directory on the local filesystem. Rocket's FileServer is mounted to serve these files over HTTP.

How It Works

  1. Files are written to the configured upload directory (default: ./uploads).
  2. Subdirectories are created automatically as needed.
  3. Rocket mounts a static file server at the configured base URL (default: /uploads).
  4. Public URLs are of the form /uploads/<path>.

Health Check

The local storage health check uses statvfs to report disk usage:

{
"provider": "local",
"status": "up",
"total_bytes": 107374182400,
"available_bytes": 53687091200,
"used_percent": 50.0
}

Configuration

Environment VariablePurposeDefault
STORAGE_PROVIDERSet to locallocal
STORAGE_LOCAL_UPLOAD_DIRDirectory path for file storage./uploads
STORAGE_LOCAL_BASE_URLURL prefix for serving files/uploads

S3-Compatible Storage

For production deployments, Forja supports S3-compatible object storage (AWS S3, MinIO, DigitalOcean Spaces, etc.) via the AWS SDK for Rust.

How It Works

  1. Files are uploaded to the configured S3 bucket using PutObject.
  2. An optional key prefix (e.g., media/) is prepended to all paths.
  3. Public URLs are constructed from the bucket name and region, or from a custom endpoint.
  4. Deletion uses DeleteObject; existence checks use HeadObject.

Public URL Formats

AWS S3:

https://<bucket>.s3.<region>.amazonaws.com/<prefix><path>

Custom endpoint (MinIO, etc.):

<endpoint>/<bucket>/<prefix><path>

Health Check

The S3 health check calls HeadBucket to verify bucket accessibility:

{
"provider": "s3",
"status": "up",
"bucket": "my-media-bucket"
}

Configuration

Environment VariablePurposeDefault
STORAGE_PROVIDERSet to s3local
STORAGE_S3_BUCKETS3 bucket nameRequired for S3
STORAGE_S3_REGIONAWS regionus-east-1
STORAGE_S3_PREFIXKey prefix (e.g., media/)(none)
STORAGE_S3_ENDPOINTCustom endpoint URL (for MinIO)(none, uses AWS)

AWS credentials are resolved via the standard AWS SDK credential chain (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, instance profiles, etc.).

Upload Flow

When a user uploads a file through the admin dashboard or API:

Client                    Handler                  Service                  Storage
│ │ │ │
│ POST /api/v1/media │ │ │
│ (multipart form) │ │ │
│ ───────────────────────▶│ │ │
│ │ Parse multipart │ │
│ │ Extract file bytes │ │
│ │ Validate type/size │ │
│ │ ──────────────────────▶│ │
│ │ │ Generate path │
│ │ │ (site_id/uuid.ext) │
│ │ │ ──────────────────────▶│
│ │ │ │ Store file
│ │ │ │ Return URL
│ │ │ ◀──────────────────── │
│ │ │ │
│ │ │ Generate variants │
│ │ │ (thumbnail, etc.) │
│ │ │ ──────────────────────▶│
│ │ │ │ Store variants
│ │ │ ◀──────────────────── │
│ │ │ │
│ │ │ Insert media record │
│ │ │ + variant records │
│ │ │ into PostgreSQL │
│ │ ◀──────────────────── │ │
│ 200 OK + MediaResponse │ │ │
│ ◀─────────────────────── │ │ │

File Path Convention

Files are stored with a path structure of:

<site_id>/<uuid>.<extension>

Variants are stored alongside the original:

<site_id>/<uuid>_thumbnail.webp
<site_id>/<uuid>_small.webp
<site_id>/<uuid>_medium.webp

Media Variants

When an image is uploaded, the ImageService generates multiple variants:

VariantPurpose
originalThe uploaded file as-is
thumbnailSmall preview (e.g., 150x150)
smallSmall display size
mediumMedium display size
largeLarge display size
webpWebP format conversion
avifAVIF format conversion

Variant metadata (URL, dimensions, file size) is stored in the media_variants table and linked to the parent media record.

Database Records

Each upload creates:

  1. One row in media -- file metadata (name, content type, size, storage path, site_id).
  2. One or more rows in media_variants -- one per generated variant.

Deleting a media record cascades to its variants and removes the files from storage.

Request Size Limits

File uploads are constrained by the security configuration:

SettingDefaultEnvironment Variable
Max file upload50 MBAPP__SECURITY__MAX_FILE_SIZE
Max form data10 MBAPP__SECURITY__MAX_FORM_SIZE
Max request body10 MBAPP__SECURITY__MAX_BODY_SIZE

The form data limit is automatically raised to match the file upload limit so that multipart uploads are not rejected prematurely.