-
Notifications
You must be signed in to change notification settings - Fork 0
Storage
MangaCollector stores two kinds of media:
- Series covers — these come from MyAnimeList's CDN and are referenced directly by URL. They are not stored by the backend.
-
User-uploaded posters — when a user replaces the MAL cover with their
own (
Volume detail → Cover picker → Upload custom), the resulting file needs a place to live. This is what the storage configuration controls.
Two backends are available: S3-compatible object storage and the local
filesystem. The choice is binary — set the S3_* vars to use S3, leave
them empty to fall back to local disk via STORAGE_DIR.
| Use case | Recommended backend |
|---|---|
| Single host, hobby deployment | Local FS via STORAGE_DIR
|
| Multi-replica behind a load balancer | S3 (any compliant provider) |
| Self-hosted, no managed S3 | MinIO or Garage in their own container |
| Serverless / k8s with no persistent volumes | S3 (mandatory — pods can't share local disk) |
Local FS is simpler to bring up but breaks when you horizontally scale the backend (each replica would write to its own volume; readers from a different replica wouldn't find the file). S3 has zero issues with that.
Activated when S3_ENDPOINT and S3_BUCKET_NAME are both set. The
relevant env block:
S3_ENDPOINT=s3.example.com
S3_ACCESS_KEY=<key>
S3_SECRET_KEY=<secret>
S3_BUCKET_NAME=mangacollector
S3_REGION=us-east-1
S3_USE_SSL=true
S3_USE_PATH_STYLE=true-
truefor MinIO, Garage, Backblaze B2, R2, and most self-hosted S3 implementations. The bucket name lives in the URL path. -
falsefor AWS S3 proper. The bucket lives in the hostname (virtual host style).
Get this wrong and you'll see 404 NoSuchKey on uploads even though the
bucket exists.
The application does not create the bucket. Pre-create it with whichever tool you prefer:
aws s3 mb s3://mangacollector --region us-east-1
# or via MinIO:
mc mb local/mangacollectorThe user attached to S3_ACCESS_KEY needs the equivalent of:
{
"Effect": "Allow",
"Action": ["s3:PutObject", "s3:GetObject", "s3:DeleteObject", "s3:ListBucket"],
"Resource": ["arn:aws:s3:::mangacollector/*", "arn:aws:s3:::mangacollector"]
}mangacollector/
├── posters/
│ └── {user-id}/
│ └── {mal-id}/
│ └── {timestamp}-{filename}.{ext}
User IDs scope the keys so a CDN signed-URL leak can't cross-pollute users.
User posters are exposed via a dedicated server endpoint — the bucket itself does not need to be public. The backend signs URLs (or proxies the bytes) per request, applying:
- ownership check for private library views
- public-poster proxy for the
/u/{slug}opt-in profile pages
This gives you full ACL control independent of the bucket policy.
Activated when none of the S3_* essentials are set, only:
STORAGE_DIR=/dataThe directory must exist and be writable by the backend's runtime user
(uid 65532 in the production image). When running in Docker, mount a
volume:
services:
server:
volumes:
- ./tmp/docker-data:/data
environment:
STORAGE_DIR: /dataSame hierarchy as S3 — just rooted at STORAGE_DIR:
${STORAGE_DIR}/
├── posters/
│ └── {user-id}/
│ └── {mal-id}/
│ └── …
rsync -avz user@host:/path/to/storage/ ./backup/
# or
docker run --rm -v mangacollector_data:/data alpine tar czf - /data > backup.tar.gzYou can move existing files without downtime if you're patient:
- Stand up the bucket and configure your
S3_*vars in a second backend replica. - Sync the existing local data into the bucket:
aws s3 sync /data/posters s3://mangacollector/posters
- Cut traffic over to the S3-configured replica.
- Tear down the local-FS replica.
The path layout is identical, so already-stored URLs in the database keep resolving.
MAL covers are referenced by URL (https://cdn.myanimelist.net/...) directly
in the library.image_url_jpg column. They are never stored locally —
the frontend lazy-loads them through Workbox's runtime cache (PWA), and
cache-bust them with the URL itself.
The only place the backend touches MAL covers is on poster upload: it doesn't download the existing MAL cover when the user replaces it; it just stores the new file and changes the source-of-truth flag on the row.
| Limit | Default | Where to bump |
|---|---|---|
| Max upload body size | 10 MB |
MAX_BODY_SIZE_MB (clamps to [1, 1024]) |
| Per-IP upload rate | 30 req / 2 s | RATE_LIMIT_* |
| Bucket storage class | provider's default | configure in S3 lifecycle rules, not in the app |
Posters are not transcoded server-side. Whatever the user uploads is what's served. If you want WebP-on-demand, put a CDN in front.
🛠️ Infrastructure
- Architecture
- Quick start (Docker)
- Production
- Local development
- Environment variables
- Authentication
- Storage
- Database & migrations
👤 User guide
- Library
- Adding a series
- Series page
- Volume editing
- Coffrets
- Calendar
- Seals
- Profile & stats
- Public profile
- Settings
- Import / export
🔌 Release proxy
📚 Reference