Self-hosted object storage in a single Rust binary.
LazyBucket is an S3-inspired object storage server you can run yourself. It keeps object bytes on disk and their metadata in SQLite, exposes an HTTP API, and ships with a built-in web UI β all in a single binary and a ~145 MB Docker image. It needs no cloud account and no services beyond itself.
Storage & API
- S3-style buckets and objects with an ergonomic REST API
- Streaming uploads and downloads β large files never sit fully in memory
- HTTP Range requests (
206 Partial Content) for seeking and resumable downloads - Conditional requests:
ETag/If-None-MatchandLast-Modified/If-Modified-Sinceβ304 - Multipart uploads with per-part
ETagvalidation, for very large files - Server-side copy and cross-bucket move
- Folder-style listing via
delimiter, plus cursor pagination - Prefix bulk-delete and custom object metadata (
x-meta-*) - Automatic content-type inference and gzip compression for JSON responses
Web UI
- Drag-and-drop uploads with progress (chunked for large files)
- Folder navigation with breadcrumbs, in-folder search, and per-object details
- Copy / move / rename / delete, copy public link, bucket & store statistics
Operations
- Single binary; one small (~145 MB) Docker container
- Liveness, readiness and Prometheus metrics endpoints
- Structured (JSON) or human-readable logging, configurable log levels
- Graceful shutdown, startup cleanup and a TTL sweeper for abandoned uploads
- Constant-time credential checks and path-traversal-safe storage
ββββββββββββββββββ HTTP / REST
β React SPA β ββββββββββββββββββββββ
ββββββββββββββββββ βΌ
βββββββββββββββββββββ
curl / SDK / any client βββββββββββΆ β Axum server β
β (single binary) β
βββββββββββββββββββββ€
β SQLite β metadata β buckets Β· objects Β· uploads
β Disk β bytes β ./storage/<bucket>/<key>
βββββββββββββββββββββ
Object bytes live on the filesystem under ./storage/<bucket>/<key>; their metadata
(size, content type, ETag, timestamps, custom x-meta-* fields) is indexed in SQLite for fast
listing and lookup. The two are kept consistent: uploads are idempotent, failed writes roll back,
and deleting or renaming a bucket cascades to its objects and files.
USER_LOGIN=admin USER_PASSWORD=secret docker compose up --buildThe database and objects are persisted in the lazybucket-data volume. Open
http://localhost:8000 and sign in with the credentials above.
docker build -t lazybucket .
docker run -p 8000:8000 \
-e USER_LOGIN=admin \
-e USER_PASSWORD=secret \
-v "$(pwd)/data:/app/data" \
-e DATABASE_URL="sqlite:///app/data/database.db?mode=rwc" \
-e STORAGE_PATH="/app/data/storage" \
lazybucket# Backend (serves the API on :8000)
USER_LOGIN=admin USER_PASSWORD=secret cargo run
# Frontend (Vite dev server, proxies /api to the backend)
cd frontend && npm install && npm run devAll configuration is via environment variables. Only the credentials are required.
| Variable | Required | Default | Description |
|---|---|---|---|
USER_LOGIN |
yes | β | Login for HTTP Basic auth |
USER_PASSWORD |
yes | β | Password for HTTP Basic auth |
HOST |
0.0.0.0 |
Bind host | |
PORT |
8000 |
Bind port | |
DATABASE_URL |
sqlite://database.db?mode=rwc |
SQLite connection URL | |
STORAGE_PATH |
./storage |
Directory for object files | |
FRONTEND_DIR |
./frontend/dist |
Built SPA directory to serve | |
MAX_UPLOAD_BYTES |
5368709120 (5 GiB) |
Maximum single upload size | |
UPLOAD_TTL_SECS |
86400 (24 h) |
Lifetime of an incomplete multipart upload before it's swept | |
CORS_ALLOWED_ORIGINS |
(empty) | Comma-separated allowed origins for cross-origin API clients. Empty = same-origin only; * = any origin. The bundled UI is same-origin and needs no CORS. |
|
RUST_LOG |
info |
Log level/filter, e.g. lazybucket=debug,tower_http=debug |
|
LOG_FORMAT |
(text) | json for structured logs; anything else = human-readable |
See .env.example for a ready-to-copy template.
Base path: /api. Requests and responses use JSON; errors return { "error": "<message>" }
with an appropriate status code.
Authentication β HTTP Basic: Authorization: Basic base64(login:password).
Public (no auth): GET /api/health, /api/version, /api/ready, /api/metrics, and object
downloads (GET/HEAD /api/:bucket/*key). Everything else requires authentication.
| Method | Path | Description |
|---|---|---|
GET |
/api/health |
Liveness β always ok |
GET |
/api/ready |
Readiness β pings the database (503 if unreachable) |
GET |
/api/version |
Build name and version |
GET |
/api/stats |
Store totals: buckets, objects, bytes (auth) |
GET |
/api/metrics |
Prometheus metrics (public) |
| Method | Path | Description |
|---|---|---|
GET |
/api/buckets |
List buckets with per-bucket object count and size |
PUT |
/api/buckets/:name |
Create a bucket |
PATCH |
/api/buckets/:name |
Rename a bucket ({"name":"new"}) |
DELETE |
/api/buckets/:name |
Delete a bucket and all its objects |
| Method | Path | Description |
|---|---|---|
GET |
/api/:bucket |
List objects β ?prefix=, ?delimiter=/ (folders), ?limit=, ?after= (pagination) |
PUT |
/api/:bucket/*key |
Upload an object (see notes) |
GET |
/api/:bucket/*key |
Download β supports Range, If-None-Match, If-Modified-Since |
HEAD |
/api/:bucket/*key |
Object metadata headers, no body |
PATCH |
/api/:bucket/*key |
Rename ({"key":"new"}) or move ({"key":"new","bucket":"other"}) |
DELETE |
/api/:bucket/*key |
Delete an object |
DELETE |
/api/:bucket?prefix=folder/ |
Bulk-delete every object under a prefix |
On upload (PUT) you can also:
- attach custom metadata with
x-meta-*request headers (echoed back onGET/HEAD); - copy an existing object instead of sending a body with
x-copy-source: <bucket>/<key>.
For very large files, upload in parts and assemble server-side:
| Method | Path | Description |
|---|---|---|
POST |
/api/:bucket/*key?uploads |
Initiate β returns { "uploadId": "β¦" } |
PUT |
/api/:bucket/*key?uploadId=..&partNumber=N |
Upload part N β returns the part's ETag |
POST |
/api/:bucket/*key?uploadId=.. |
Complete β optionally send { "parts": [{ "partNumber", "etag" }] } to validate |
DELETE |
/api/:bucket/*key?uploadId=.. |
Abort and discard the session |
BASE=http://localhost:8000/api
AUTH='-u admin:secret'
# Create a bucket and upload an object with metadata
curl $AUTH -X PUT "$BASE/buckets/photos"
curl $AUTH -X PUT --data-binary @cat.jpg \
-H 'x-meta-author: alice' "$BASE/photos/animals/cat.jpg"
# Browse like folders
curl $AUTH "$BASE/photos?delimiter=/" # β { objects, prefixes, next }
# Download a byte range
curl "$BASE/photos/animals/cat.jpg" -H 'Range: bytes=0-1023' -o head.bin
# Server-side copy, then move to another bucket
curl $AUTH -X PUT -H 'x-copy-source: photos/animals/cat.jpg' "$BASE/photos/backup/cat.jpg"
curl $AUTH -X PATCH -H 'Content-Type: application/json' \
-d '{"key":"cat.jpg","bucket":"archive"}' "$BASE/photos/backup/cat.jpg"- Credentials are compared in constant time; every rejection returns
401with aWWW-Authenticatechallenge. - Bucket names and object keys are validated against path traversal before touching disk.
- CORS is closed by default; open it explicitly per origin via
CORS_ALLOWED_ORIGINS. - LazyBucket speaks plain HTTP β terminate TLS at a reverse proxy (nginx, Caddy, Traefik)
in production, and use a strong
USER_PASSWORD.
cargo test # unit + HTTP integration tests
cargo fmt --all --check # formatting
cargo clippy --all-targets -- -D warnings # lints
cd frontend && npm run build # build the SPA
docker compose up --build # full end-to-end runCI (GitHub Actions) runs formatting, Clippy, the test suite, and the frontend build on every push and pull request.
Project layout
src/
main.rs binary entrypoint (config, startup, graceful shutdown)
lib.rs router assembly, CORS/compression, upload sweeper
api/ handlers, auth middleware, typed error responses
db/ SQLite access: buckets, objects, uploads, stats
storage/ on-disk object storage + name validation
migrations/ schema migrations (embedded at build time)
frontend/ React + Vite single-page app