Simple HTTP service for storing and retrieving binary files (blobs) with metadata.
This project automatically builds and publishes a Docker image to GitHub Container Registry (GHCR) on every push to the main branch.
docker pull ghcr.io/sebastianjnuwu/blob:latestdocker run -d \
-p 3000:3000 \
--env-file .env \
-v ./storage:/storage \
--name blob \
ghcr.io/sebastianjnuwu/blob:latest-
-dRuns the container in the background. -
-p 3000:3000Maps port 3000 of the container to port 3000 on the host. -
--env-file .envLoads environment variables from the.envfile. -
-v ./storage:/storageMounts a persistent storage directory so uploaded files are not lost. -
--name blobGives the container an easy name for management.
| Method | Route | Private | Description |
|---|---|---|---|
| PUT | /blob |
true | Upload a blob |
| POST | /blob/initiate |
true | Initiate multipart upload (huge file) |
| PUT | /blob/:id/chunk |
true | Upload a chunk (multipart) |
| POST | /blob/:id/complete |
true | Complete multipart upload |
| GET | /blob/:id/status |
true | Check multipart upload status |
| GET | /blob |
true | List blobs |
| GET | /blob/:id |
true | Get blob metadata |
| POST | /blob/:id |
true | Edit blob fields |
| GET | /blob/:id/download |
false | Download blob file |
| DELETE | /blob/:id |
true | Delete blob |
| GET | /metrics |
true | Storage and usage metrics (JSON) |
| GET | /health |
false | Healthcheck |
| GET | / |
false | Hello, World |
| Column | Type | Nullable | Description |
|---|---|---|---|
| id | UUID | No | Unique identifier |
| bucket | TEXT | No | Logical group of files |
| filename | TEXT | No | File name |
| mime | TEXT | No | MIME type |
| size | BIGINT | No | File size in bytes |
| hash | TEXT | No | SHA256 hash of the file |
| path | TEXT | No | Storage path |
| public | BOOLEAN | Yes | Whether blob is public |
| download_count | INT | No | Number of downloads |
| metadata | JSONB | Yes | Additional metadata (JSON) |
| created_at | TIMESTAMPTZ | No | Creation timestamp |
| updated_at | TIMESTAMPTZ | No | Last update timestamp |
| expires_at | TIMESTAMPTZ | Yes | Optional expiration date |
| deleted_at | TIMESTAMPTZ | Yes | Soft delete timestamp |
curl http://localhost:3000/Response:
{
"message": "Hello, World!"
}curl http://localhost:3000/healthResponse:
{
"status": "ok"
}curl -X GET http://localhost:3000/metrics \
-H "Authorization: Bearer change-me-with-32-characters-or-more"Response:
{
"buckets": [
{
"blobs": 9,
"name": "testbucket",
"size": "8.32 MB",
"visibility": {
"private": 9,
"public": 0
}
}
],
"last_upload": {
"bucket": "testbucket",
"created_at": "2026-03-08T09:59:47.420216-03:00",
"filename": "video.mp4",
"id": "33fc6434-ab9c-43ad-b7f6-63bb7f92704c"
},
"summary": {
"average_size": "946.49 KB",
"max_size": "946.49 KB",
"min_size": "946.49 KB",
"multipart_completed": 4,
"storage_free": "1015.68 MB",
"storage_max": "1.00 GB",
"total_blobs": 9,
"total_downloads": 0,
"total_size": "8.32 MB"
},
"types": [
{
"count": 9,
"mime": "video/mp4",
"size": "8.32 MB"
}
]
}Uploads a new blob.
| Field | Required | Type | Description |
|---|---|---|---|
| file | Yes | file | File to upload |
| bucket | Yes | string | Logical group |
| filename | No | string | Custom filename |
| public | No | boolean | Accepts true, false, 0, 1 (default: true) |
| expires_at | No | string | RFC3339 date |
| metadata | No | string | JSON metadata |
curl -X PUT http://localhost:3000/blob \
-H "Authorization: Bearer change-me-with-32-characters-or-more" \
-F "file=@README.md" \
-F "bucket=test" \
-F "filename=custom_name.txt" \
-F "public=false" \
-F "expires_at=2026-03-02T12:00:00Z" \
-F "metadata={\"author\":\"user\",\"desc\":\"test file\"}"Response:
{
"id": "1ddff9d2-3aa1-485d-8082-e484c62ff630",
"bucket": "test",
"filename": "custom_name.txt",
"mime": "application/octet-stream",
"size": 3625,
"hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"path": "test/1ddff9d2-3aa1-485d-8082-e484c62ff630",
"public": false,
"download_count": 0,
"created_at": "2026-03-07T12:31:05.2082654-03:00",
"updated_at": "2026-03-07T12:31:05.2082654-03:00",
"expires_at": "2026-03-02T12:00:00Z",
"metadata": {
"author": "user",
"desc": "test file"
}
}curl -X POST http://localhost:3000/blob/initiate \
-H "Content-Type: application/json" \
-H "X-User-ID: <user-uuid>" \
-d '{"bucket":"bigfiles","filename":"video_20tb.mkv","size":21990232555520}'
# Response: { "uploadId": "abc123" }CHUNK_HASH=$(sha256sum chunk_0.bin | awk '{print $1}')
curl -X PUT http://localhost:3000/blob/abc123/chunk \
-H "X-User-ID: <user-uuid>" \
-H "X-Chunk-Index: 0" \
-H "X-Chunk-Hash: $CHUNK_HASH" \
--data-binary "@chunk_0.bin"
# Repeat for each chunk, incrementing index and hashcurl -H "X-User-ID: <user-uuid>" http://localhost:3000/blob/abc123/statusFINAL_HASH=$(sha256sum full_file.bin | awk '{print $1}')
curl -X POST http://localhost:3000/blob/abc123/complete \
-H "X-User-ID: <user-uuid>" \
-H "X-Final-Hash: $FINAL_HASH"After completion, the file is available as a normal blob for download and management.
Configure minimum and maximum chunk size in .env:
BLOB_MIN_CHUNK_SIZE=1048576 # 1MB
BLOB_MAX_CHUNK_SIZE=20971520 # 20MB
Chunks outside these limits will be rejected.
X-Chunk-Hash: Required for each chunk (SHA256 hex of chunk)X-Final-Hash: Required when completing upload (SHA256 hex of full file)
Abandoned multipart uploads are cleaned up automatically after a configurable threshold (see .env).
Returns paginated blobs.
| Parameter | Required | Type | Description |
|---|---|---|---|
| bucket | No | string | Filter by bucket |
| search | No | string | Search filename |
| page | No | int | Page number (default: 1) |
| page_size | No | int | Items per page |
curl "http://localhost:3000/blob?bucket=test&search=report&page=1&page_size=10"Response:
{
"meta": {
"page": 1,
"per_page": 10,
"count": 1,
"pages": 1,
"total": 42
},
"blobs": [
{
"id": "...",
"bucket": "test",
"filename": "report1.pdf",
"mime": "application/pdf",
"size": 12345,
"hash": "...",
"path": "test/...",
"public": true,
"download_count": 0,
"created_at": "2026-03-07T12:31:05.2082654-03:00",
"updated_at": "2026-03-07T12:31:05.2082654-03:00",
"expires_at": null,
"metadata": {
"author": "user"
}
}
// ...more blobs
]
}Returns blob metadata as JSON (does not download the file).
curl http://localhost:3000/blob/1ddff9d2-3aa1-485d-8082-e484c62ff630Response:
{
"id": "1ddff9d2-3aa1-485d-8082-e484c62ff630",
"bucket": "test",
"filename": "custom_name.txt",
"mime": "application/octet-stream",
"size": 3625,
"hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"path": "test/1ddff9d2-3aa1-485d-8082-e484c62ff630",
"public": false,
"download_count": 0,
"created_at": "2026-03-07T12:31:05.2082654-03:00",
"updated_at": "2026-03-07T12:31:05.2082654-03:00",
"expires_at": "2026-03-02T12:00:00Z",
"metadata": {
"author": "user",
"desc": "test file"
}
}Edits blob fields: metadata, public/private, expiration date, bucket, and filename. Requires authentication.
| Field | Required | Type | Description |
|---|---|---|---|
| metadata | No | object | New metadata (JSON object) |
| public | No | boolean | Set blob as public or private |
| expires_at | No | string | RFC3339 expiration date |
| bucket | No | string | Change bucket name |
| filename | No | string | Change filename |
curl -X POST http://localhost:3000/blob/1ddff9d2-3aa1-485d-8082-e484c62ff630 \
-H "Authorization: Bearer change-me-with-32-characters-or-more" \
-H "Content-Type: application/json" \
-d '{
"metadata": {"author": "newuser", "desc": "updated file"},
"public": true,
"expires_at": "2026-04-01T12:00:00Z",
"filename": "new_name.txt"
}'Response:
{
"id": "1ddff9d2-3aa1-485d-8082-e484c62ff630",
"bucket": "bucket",
"filename": "new_name.txt",
"mime": "application/octet-stream",
"size": 3625,
"hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"path": "newbucket/1ddff9d2-3aa1-485d-8082-e484c62ff630",
"public": true,
"download_count": 0,
"created_at": "2026-03-07T12:31:05.2082654-03:00",
"updated_at": "2026-03-07T12:31:05.2082654-03:00",
"expires_at": "2026-04-01T12:00:00Z",
"metadata": {
"author": "newuser",
"desc": "updated file"
}
}For public blobs, simply access the route:
curl -X GET \
http://localhost:3000/blob/1ddff9d2-3aa1-485d-8082-e484c62ff630/download \
-o downloaded_file.extFor private blobs, you must provide either:
-
The SHA256 hash of the file as a query parameter:
curl -X GET \ "http://localhost:3000/blob/1ddff9d2-3aa1-485d-8082-e484c62ff630/download?hash=YOUR_FILE_HASH" \ -o downloaded_file.ext -
Or a valid authentication token in the Authorization header:
curl -X GET \ http://localhost:3000/blob/1ddff9d2-3aa1-485d-8082-e484c62ff630/download \ -H "Authorization: Bearer YOUR_TOKEN_HERE" \ -o downloaded_file.ext
If neither a valid hash nor a valid token is provided for a private blob, the download will be denied.
Deletes a blob, its metadata, and the file from disk. Requires authentication.
curl -X DELETE http://localhost:3000/blob/1ddff9d2-3aa1-485d-8082-e484c62ff630 \
-H "Authorization: Bearer change-me-with-32-characters-or-more"Response:
{
"message": "Blob deleted successfully"
}