File Storage
Grant uses file storage for user-facing uploads such as profile pictures. The storage layer follows the same adapter pattern as the rest of the platform — a port defined in @grantjs/core, with swappable adapters in @grantjs/storage.
Adapters
| Adapter | Provider value | Best for |
|---|---|---|
| Local | local | Development, single-server deployments, Docker volumes, NFS mounts |
| S3 | s3 | Production, multi-server deployments, S3-compatible services (AWS S3, MinIO, DigitalOcean Spaces) |
Switching adapters requires only an environment variable change — no code changes. Existing files must be migrated manually.
Configuration
Local storage
STORAGE_PROVIDER=local
STORAGE_LOCAL_BASE_PATH=./storage # or /app/storage in DockerThe path can point to any mounted filesystem (local directory, Docker volume, NFS mount).
S3 storage
STORAGE_PROVIDER=s3
STORAGE_S3_BUCKET=my-bucket
STORAGE_S3_REGION=us-east-1
STORAGE_S3_ACCESS_KEY_ID=your-access-key
STORAGE_S3_SECRET_ACCESS_KEY=your-secret-key
STORAGE_S3_ENDPOINT=https://minio.example.com # optional: S3-compatible endpoint
STORAGE_S3_PUBLIC_URL=https://cdn.example.com # optional: CDN base URLWhen STORAGE_S3_PUBLIC_URL is set, public file URLs use this base instead of the S3 bucket URL — useful for CloudFront or other CDN distributions.
Upload constraints
| Variable | Default | Description |
|---|---|---|
STORAGE_UPLOAD_MAX_FILE_SIZE | 5242880 (5 MB) | Maximum upload size in bytes |
Allowed MIME types and file extensions are configured in env.config.ts. By default, only image types are permitted: image/jpeg, image/png, image/gif, image/webp.
Adapter Interface
Both adapters implement the IFileStorageService port from @grantjs/core:
interface IFileStorageService {
upload(file: Buffer, path: string, options?: UploadOptions): Promise<UploadResult>;
delete(path: string): Promise<void>;
getUrl(path: string): Promise<string>;
exists(path: string): Promise<boolean>;
copy(sourcePath: string, destinationPath: string): Promise<void>;
getUploadUrl(path: string, options: UploadUrlOptions): Promise<UploadUrlResult>;
getMetadata(path: string): Promise<StoredObjectMetadata | null>;
download(path: string, options: { maxBytes: number }): Promise<Buffer>;
}getUploadUrl, getMetadata and download are required of every adapter, not optional: see ADR 0007 and ADR 0009. download reads a whole object into a Buffer. maxBytes is required and enforced inside the method: an object over it throws PayloadTooLargeError before it is buffered. It throws NotFoundError when nothing is stored at the path, and refuses the same malformed paths as getUploadUrl.
The API wraps this in a FileStorageService (apps/api/src/services/file-storage.service.ts) that adds validation (file type, extension, size) and base64 decoding before delegating to the adapter.
Upload Flow
When a user uploads a profile picture, the flow is:
- Client sends the file as a base64-encoded string with content type and filename
FileStorageService.validateAndDecodeUpload()validates the MIME type, extension, and size, then decodes the base64 payload- A storage path is generated:
users/{userId}/picture.{ext} - The adapter uploads the file and returns a public URL
- The user record is updated with the new
pictureUrl
Organization logos use the same flow with path organizations/{organizationId}/picture.{ext} and persist the URL on organizations.picture_url.
Project logos use projects/{projectId}/picture.{ext}. Project-app logos use project-apps/{projectAppId}/picture.{ext}. Both write through mint / confirm / upload (or a clear action). UpdateProject / UpdateProjectApp do not accept pictureUrl. On read, Grant hydrates a public URL from the stored path.
Project membership pictures use users/{userId}/projects/{projectId}/picture.{ext}.
TIP
The adapter is instantiated once at startup via StorageFactory.createStorageService() and receives an ILoggerFactory via dependency injection — it never imports the logger directly.
Related:
- Configuration — All environment variables
- Architecture Overview — Package dependency graph