File Storage & Avatars
LaunchKit Pro includes workspace-scoped file storage with LOCAL storage by default and a provider-ready foundation for S3-compatible storage, Cloudflare R2, and Cloudinary. Profile avatars continue to work through the same backend storage service.
Local storage provider
| Setting | Default | Description |
|---|---|---|
STORAGE_PROVIDER | LOCAL | Active provider |
STORAGE_UPLOAD_DIR | uploads | Directory on server disk |
STORAGE_PUBLIC_BASE_URL | http://localhost:4000/uploads | Public base URL for local public assets |
STORAGE_MAX_FILE_SIZE_MB | 25 | System upload ceiling |
STORAGE_ALLOWED_MIME_TYPES | common image/pdf/text/zip types | Server allow-list |
Production example:
STORAGE_UPLOAD_DIR=/var/www/yourapp/shared/uploads
STORAGE_PUBLIC_BASE_URL=https://api.yourdomain.com/uploadsProduction uploads directory
Use a shared folder outside release directories (e.g. /var/www/yourapp/shared/uploads) so deploys do not wipe uploaded files. The API process user must read/write this path.
Ensure the API process user can read/write this directory.
Do not ship buyer upload folders in marketplace ZIPs — uploads are created at runtime on the server.
Public asset URLs
LOCAL public assets are served by the API:
https://api.yourdomain.com/uploads/...The frontend resolves asset URLs using NEXT_PUBLIC_API_URL. Example avatar URL:
https://api.yourdomain.com/uploads/avatars/<filename>.webpCross-origin loading from the app domain requires the API to allow browser access to /uploads (LaunchKit Pro sets appropriate static asset headers for avatar display).
Profile avatars
Frontend crop and resize
Before upload, the dashboard profile page (/dashboard/profile) opens a crop dialog:
- User selects JPEG, PNG, or WebP
- Square 1:1 crop with pan and zoom
- Client exports 512×512 WebP (or JPEG fallback) at ~0.85 quality
- Smaller payloads load faster in profile and topbar
API upload
curl -s -X POST http://localhost:4000/profile/avatar \
-H "Authorization: Bearer <accessToken>" \
-F "avatar=@./avatar.webp"| Item | Value |
|---|---|
| Method | POST /profile/avatar |
| Field name | avatar |
| Allowed types (client + server) | JPEG, PNG, WebP |
After upload, the avatar appears on the profile page and dashboard topbar. Initials fallback is used when no avatar is set or the image fails to load.
Display sizes (frontend)
| Location | Size |
|---|---|
| Profile page | 128×128 (rounded) |
| Dashboard / admin topbar | 40×40 (rounded) |
Fixed dimensions and object-fit: cover prevent layout shift.
Provider status
curl -s http://localhost:4000/storage/provider-status \
-H "Authorization: Bearer <accessToken>" | jqThe response includes only safe fields: provider, configured flag, upload limits, signed URL TTL, and a friendly message. It never exposes access keys or secrets.
Upload
curl -s -X POST http://localhost:4000/workspaces/<workspaceId>/files \
-H "Authorization: Bearer <accessToken>" \
-F "file=@./sample.pdf" \
-F "category=DOCUMENT" | jqRequires files.upload permission.
Flat API aliases are also available for frontend and API testing:
curl -s -X POST http://localhost:4000/storage/upload \
-H "Authorization: Bearer <accessToken>" \
-F "workspaceId=<workspaceId>" \
-F "file=@./sample.pdf" \
-F "category=DOCUMENT" | jqList and download
curl -s "http://localhost:4000/workspaces/<workspaceId>/files?page=1&limit=20" \
-H "Authorization: Bearer <accessToken>" | jq
curl -s "http://localhost:4000/storage/files?workspaceId=<workspaceId>" \
-H "Authorization: Bearer <accessToken>" | jq
curl -s http://localhost:4000/storage/files/<fileId>/signed-url \
-H "Authorization: Bearer <accessToken>" | jq
curl -s -O -J http://localhost:4000/workspaces/<workspaceId>/files/<fileId>/download \
-H "Authorization: Bearer <accessToken>"Delete
curl -s -X DELETE http://localhost:4000/workspaces/<workspaceId>/files/<fileId> \
-H "Authorization: Bearer <accessToken>"Requires files.delete. Demo mode blocks delete for protected demo users.
Allowed file types
Backend validates MIME types and categories per workspace storage config. Common categories: DOCUMENT, IMAGE, GENERAL, etc.
Check effective config:
curl -s http://localhost:4000/workspaces/<workspaceId>/storage/config \
-H "Authorization: Bearer <accessToken>" | jqUpload size configuration
Effective limit follows:
min(STORAGE_MAX_FILE_SIZE_MB, plan limit, workspace upload setting)Align:
- Backend
STORAGE_MAX_FILE_SIZE_MB - Frontend
NEXT_PUBLIC_MAX_UPLOAD_SIZE_MB - Nginx
client_max_body_size
See Permissions & Limits.
S3-compatible storage
Set:
STORAGE_PROVIDER=S3
S3_ACCESS_KEY_ID=...
S3_SECRET_ACCESS_KEY=...
S3_REGION=...
S3_BUCKET=...
S3_ENDPOINT=
S3_FORCE_PATH_STYLE=false
S3_PUBLIC_BASE_URL=Only S3 settings are validated when S3 is active or used. Inactive provider keys may remain empty.
Cloudflare R2
STORAGE_PROVIDER=R2
R2_ACCESS_KEY_ID=...
R2_SECRET_ACCESS_KEY=...
R2_ACCOUNT_ID=...
R2_BUCKET=...
R2_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com
R2_PUBLIC_BASE_URL=R2 uses S3-compatible signing with region auto.
Cloudinary
STORAGE_PROVIDER=CLOUDINARY
CLOUDINARY_CLOUD_NAME=...
CLOUDINARY_API_KEY=...
CLOUDINARY_API_SECRET=...
CLOUDINARY_FOLDER=launchkit-proCloudinary uploads use resource type auto. Signed/private download support is a foundation: the API returns a provider URL where supported and a friendly error if a provider is not configured.
Signed URLs and cleanup
STORAGE_SIGNED_URL_TTL_SECONDS=900
STORAGE_CLEANUP_ENABLED=false
STORAGE_ORPHAN_MAX_AGE_DAYS=7GET /storage/files/:fileId/signed-url returns a public URL for public files or a signed/provider URL for private files after access checks. POST /storage/cleanup/orphans is admin-only and dry-run by default; it reports counts and does not delete files unless future cleanup behavior is explicitly enabled.
Provider secrets stay backend-only. The frontend only calls authenticated API routes.
Provider migration note
Switching from LOCAL to cloud affects new uploads. Migration of existing local files to S3/R2/Cloudinary is future work; keep local uploads backed up until you migrate them.
Backup note (local uploads)
Local files in LOCAL_UPLOAD_DIR are not in PostgreSQL backups. Include upload directory in:
- Filesystem backups (rsync, snapshots)
- Disaster recovery runbooks
If you only backup the database, uploaded files will be lost on restore.
Related
- Troubleshooting — upload 413 errors
- Deployment — Nginx body size