Skip to content

Storage

dim145 edited this page Apr 26, 2026 · 1 revision

Storage

MangaCollector stores two kinds of media:

  1. Series covers — these come from MyAnimeList's CDN and are referenced directly by URL. They are not stored by the backend.
  2. 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.


Choosing a backend

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.


S3 mode

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

S3_USE_PATH_STYLE

  • true for MinIO, Garage, Backblaze B2, R2, and most self-hosted S3 implementations. The bucket name lives in the URL path.
  • false for 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.

Bucket setup

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/mangacollector

The 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"]
}

Object layout

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.

Public visibility

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.


Local filesystem mode

Activated when none of the S3_* essentials are set, only:

STORAGE_DIR=/data

The 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: /data

Object layout (local)

Same hierarchy as S3 — just rooted at STORAGE_DIR:

${STORAGE_DIR}/
├── posters/
│   └── {user-id}/
│       └── {mal-id}/
│           └── …

Backups

rsync -avz user@host:/path/to/storage/ ./backup/
# or
docker run --rm -v mangacollector_data:/data alpine tar czf - /data > backup.tar.gz

Switching from local to S3

You can move existing files without downtime if you're patient:

  1. Stand up the bucket and configure your S3_* vars in a second backend replica.
  2. Sync the existing local data into the bucket:
    aws s3 sync /data/posters s3://mangacollector/posters
  3. Cut traffic over to the S3-configured replica.
  4. Tear down the local-FS replica.

The path layout is identical, so already-stored URLs in the database keep resolving.


What about MAL covers?

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.


Quotas & limits

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.

Clone this wiki locally