Blob Storage
Binary file storage on an S3-compatible backend (Garage by default) with per-user namespacing: uploads with optional TTL auto-expiry, signed URLs for unauthenticated download, prefix listing with cursor pagination, copy/move, batch delete, and per-user storage quotas with admin controls.
When to reach for it: avatars, PDFs, videos, generated reports - any binary asset that needs temporary shareable download links or per-user storage accounting.
When not to: JSON configuration and structured values belong to KV Store, whose binary endpoints also suit small blobs that never need signed URLs; collaboratively edited JSON documents belong to Live JSON.
Concepts
- Paths are storage keys, namespaced per user -
user/avatars/123.pngis a key, not a filesystem path. Two users can write the same path without collision, and reading another user's path is a plain404 blob_not_found, never a 403. Traversal attempts (..components) fail with400 invalid_blob_path. - Content type is auto-detected from the file extension on upload;
pass
-tto override. - TTL is optional and per blob - an expired blob answers
410 blob_expiredon read; physical deletion is deferred to the retention worker (off by default). - Signed URLs are the sharing mechanism:
presignmints a/blob/public?token=...link the API serves without authentication. - Quotas - every user gets a byte budget (1 GB by default) and optionally a file-count cap; admins can override either per user.
Upload and download
snug blobs upload -f logo.png -p docs4/avatars/logo.png
snug blobs upload -f data.bin -p docs4/tmp/short.bin -t application/octet-stream --ttl 120
snug blobs download -p docs4/avatars/logo.png -o copy.png # -o defaults to the blob's filename
snug blobs head --path docs4/avatars/logo.png
snug blobs delete --path docs4/avatars/logo.png --forceOver HTTP an upload is a PUT of the raw bytes (or a multipart POST) to
/api/v1/blobs/{path}, with the TTL as a query parameter:
printf 'report body' | curl -X PUT -H "Authorization: Bearer $SNUG_API_TOKEN" \
-H "Content-Type: application/pdf" --data-binary @- \
"http://localhost:4000/api/v1/blobs/docs4/drafts/report.pdf?ttl_seconds=600"{
"status": 201,
"msg": "Created",
"data": {
"path": "docs4/drafts/report.pdf",
"size": 11,
"content_type": "application/pdf",
"created_at": 1787891618,
"expires_at": 1787892218
}
}Timestamps are Unix seconds; expires_at is null when no TTL was set.
GET /api/v1/blobs/{path} does not stream by default - it answers 307
with a Location pointing at a freshly signed /blob/public URL. Pass
?direct=true to stream the bytes in-band, which is what the CLI's
download does.
Signed URLs and public serving
snug --output json blobs generate-url -p docs4/avatars/logo.png -e 300{
"url": "http://localhost:4000/blob/public?token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...",
"expires_at": 1787891903
}Anyone holding the URL can download the blob until it expires - no
Authorization header, no account. Validity defaults to 3600 seconds. The
URL points at the API's own public route, GET /blob/public?token=... at
the server root (not under /api/v1), never directly at S3; a tampered or
expired token gets 401 blob_unauthorized.
Listing
snug blobs list --prefix docs4/ --limit 50
snug blobs list --prefix docs4/ --cursor <cursor-from-previous-page>{
"blobs": [
{
"path": "docs4/avatars/logo.png",
"size": 23,
"content_type": "image/png",
"last_modified": 1787891591,
"expires_at": null
}
],
"page_size": 1,
"has_more": true,
"cursor": "XVZFUjRRa1JpY0doTmRWZFhhR1pM...",
"prefix": "docs4/"
}Pagination is cursor-based, not page-numbered: keep passing the returned
cursor until has_more is false. One gotcha, reproduced live: list
entries always report expires_at: null, even for blobs uploaded with a
TTL - head the individual path to see the real expiry.
Copy, move, batch delete
snug blobs copy -s docs4/drafts/report.pdf -d docs4/documents/report.pdf
snug blobs move -s docs4/documents/report.pdf -d docs4/final/report.pdf
snug blobs batch-delete -p docs4/final/report.pdf -p docs4/old.bin --forcecopy carries both the content type and the remaining TTL to the
destination. move is copy-then-delete: afterwards the source is
404 blob_not_found (verified). batch-delete returns
{"deleted": N, "failed": []} and counts paths that never existed as
deleted - failed only reports backend errors, so don't use it to detect
typos.
Usage and quotas
snug --output json blobs usage{
"file_count": 2,
"total_size_bytes": 46,
"quota_max_bytes": 1073741824,
"quota_max_files": null,
"usage_percent": 4.284083843231201e-6
}An upload that would cross the byte or file budget fails with
413 quota_exceeded, and the message states current usage, upload size,
and the limit (reproduced live against a deliberately tiny quota).
Admin operations
These commands hit /admin/blobs/* and require a platform admin token;
other callers get 403 FORBIDDEN.
snug blobs admin set-quota -u user-123 --max-bytes 1073741824
snug blobs admin set-quota -u user-123 --unlimited -n "Premium tier"
snug blobs admin get-quota -u user-123
snug blobs admin list-quotas
snug blobs admin delete-quota -u user-123 --force
snug blobs admin stats # global bytes, files, user count
snug blobs admin user-details -u user-123 # one user's usage plus quota
snug blobs admin list -u user-123 --prefix docs4/admin list shows fully qualified backing keys ({tenant}/{user}/{path}),
the clearest picture of how per-user namespacing is laid out in the bucket.
Limits and configuration
Blobs up to 100 MB and per-user budgets of 1 GB with unlimited files by
default - along with the S3 endpoint and credentials, signed-URL lifetime,
the required BLOB_SIGNING_SECRET, and the retention worker for expired
blobs - via the BLOB_* variables in the
Blob CONFIG reference.
Reference
- Blob Storage API - every endpoint, callable
- Blob Storage admin API - quotas and global stats
- Related: KV Store for JSON values and small binaries, Live JSON for collaborative JSON documents