Goal: Self-hostable, open-source blob storage with S3-compatible API Stack: .NET 10 API + Blazor Server UI + SQLite or PostgreSQL + Filesystem storage Status: v1.2.2 — production-ready single-node deployment
Scope: Single-node object storage for homelabs, internal tools, and dev/test environments. Not yet suitable for mission-critical data — no replication, high availability, or point-in-time recovery.
Docker image: ghcr.io/centrolabs/objex
# Docker
docker pull ghcr.io/centrolabs/objex:latest
docker compose up -d
# Docker with PostgreSQL
docker compose -f docker-compose.postgres.yml up -d
# Kubernetes
helm install objex ./charts/objex
# From source
git clone https://github.com/centrolabs/ObjeX.git
cd ObjeX/src/ObjeX.Api
dotnet runOpen http://localhost:9001 — log in with admin / admin.
- Blazor UI: http://localhost:9001
- Job dashboard: http://localhost:9001/hangfire
- Health check: http://localhost:9001/health (liveness)
- Health check (readiness): http://localhost:9001/health/ready
⚠️ Change the default admin credentials before exposing the instance publicly. SetDefaultAdmin:Username,DefaultAdmin:Email, andDefaultAdmin:Passwordinappsettings.jsonor environment variables.
Bind mounts: If using a bind mount instead of a named volume, mount to
/data(e.g../my-data:/data). The entrypoint automatically fixes ownership for bind mounts.
ObjeX uses two auth mechanisms on separate ports:
| Port | Used by | Auth mechanism |
|---|---|---|
9001 |
Browser / Blazor UI | Cookie (ASP.NET Core Identity) |
9000 |
S3 clients, SDKs, CLI | AWS Signature Version 4 |
Public exposure: Only the S3 port needs to be publicly reachable — put it behind a reverse proxy or tunnel (e.g.
s3.example.com). The UI port should stay on your internal network. Requests are dispatched by the port they arrive on, not by hostname, so any public hostname or port works in front — see Reverse proxy for the two things the proxy must do. Configure client apps with the public S3 URL as their endpoint so both server-side SDK calls and browser presigned URLs use the same hostname.
| Role | Access |
|---|---|
| Admin | Everything — user management, role promotion, all buckets, Hangfire, presigned URL settings |
| Manager | Users page, Settings (incl. presigned URLs), all buckets — cannot change roles |
| User | Own buckets only, S3 credentials, dark mode |
The seeded admin account is permanent and cannot be deleted or demoted. Create users via Users → New User (Admin or Manager only).
Create credentials in Settings → S3 Credentials. The secret access key is shown once on creation — save it.
Point any S3-compatible client (AWS CLI, AWS SDKs, rclone, s3cmd, etc.) at http://localhost:9000 with your access key and secret.
All endpoints on port 9001 except /account/* and /health/* require a session cookie. Port 9000 (S3 API) requires AWS Signature V4.
Used by the Blazor UI. Bucket/object CRUD is handled entirely through the S3 API on port 9000 — the Blazor UI calls services directly via DI, so no REST endpoints are needed for those.
| Method | Path | Description |
|---|---|---|
GET |
/api/objects/{bucket}/{*key} |
Download an object (browser file download) — only images, audio, video, PDF and plain text are shown inline, everything else downloads |
GET |
/api/objects/{bucket}/download |
Download objects as ZIP — accepts ?prefix= to scope to a folder |
GET |
/api/presign/{bucket}/{*key} |
Generate a presigned URL — accepts ?expires=N (seconds) |
POST |
/account/login |
Form login (sets cookie), redirects to returnUrl |
GET |
/account/logout |
Clears cookie, redirects to /login |
GET |
/health |
Liveness check (200 if process is up) |
GET |
/health/live |
Liveness check (alias for /health) |
GET |
/health/ready |
Readiness check (DB connectivity + blob storage writability) |
GET |
/metrics |
Prometheus metrics (HTTP stats + per-bucket storage gauges); requires Metrics:Enabled=true |
Exposed on a dedicated port for drop-in compatibility with S3 clients (aws-cli, boto3, s3cmd, AWS SDK, etc.). Auth is AWS Signature Version 4 — create credentials in Settings → S3 Credentials.
| Method | Path | Description |
|---|---|---|
GET |
/ |
List all buckets (S3 XML) |
HEAD |
/{bucket} |
Bucket exists check |
GET |
/{bucket}?location |
Get bucket location (returns us-east-1) |
GET |
/{bucket}?uploads |
List active multipart uploads |
PUT |
/{bucket} |
Create bucket |
DELETE |
/{bucket} |
Delete bucket |
PUT |
/{bucket}/{*key} |
Upload object; supports x-amz-copy-source for server-side copy and x-amz-meta-* custom metadata |
PUT |
/{bucket}/{*key}?partNumber=N&uploadId=X |
Upload part (multipart) |
GET |
/{bucket}/{*key} |
Download object (?download=true forces attachment); Range requests supported |
GET |
/{bucket}/{*key}?uploadId=X |
List parts |
HEAD |
/{bucket}/{*key} |
Object metadata (includes x-amz-meta-* headers) |
DELETE |
/{bucket}/{*key} |
Delete object |
POST |
/{bucket}?delete |
Batch delete objects (XML key list) |
DELETE |
/{bucket}/{*key}?uploadId=X |
Abort multipart upload |
POST |
/{bucket}/{*key}?uploads |
Initiate multipart upload |
POST |
/{bucket}/{*key}?uploadId=X |
Complete multipart upload |
POST |
/{bucket} |
S3 POST Object (presigned POST via form-field auth) |
POST |
/ |
S3 POST Object (bucket endpoint mode; bucket from form field) |
GET |
/{bucket}?versioning|lifecycle|policy|cors|encryption|tagging|acl |
501 Not Implemented (stub) |
Configure S3:PublicUrl in appsettings.json (default http://localhost:9000) — used by presigned URL generation and the Blazor UI.
No config required for local dev. Defaults (from appsettings.json):
| Setting | Default |
|---|---|
| UI / API port | 9001 — set Server:UiPort |
| S3 API port | 9000 — set Server:S3Port; S3-compatible endpoints, AWS Signature V4 required |
| Reverse proxy | off — set ReverseProxy:Enabled=true plus ReverseProxy:KnownProxies / ReverseProxy:KnownNetworks to trust X-Forwarded-For / X-Forwarded-Proto |
| Login lockout | 5 failed attempts lock the account for 5 minutes — set Auth:Lockout:MaxFailedAttempts / Auth:Lockout:DurationMinutes |
| S3 public URL | http://localhost:9000 — set S3:PublicUrl for production |
| Database provider | sqlite — set Database:Provider=postgresql for Postgres |
| Database | ./data/db/objex.db (SQLite default); set ConnectionStrings:DefaultConnection for Postgres |
| Blob storage | ./data/blobs |
| Relative paths | resolved against the content root: src/ObjeX.Api/ under dotnet run, /app in the container — never against the shell's working directory |
| Log files | ./data/logs/objex-YYYYMMDD.log — daily rolling, 30 days retention, compact JSON |
| Auto-migrate | true — set Database:AutoMigrate=false to disable startup migrations |
| Max upload size | unlimited — set Storage:MaxUploadBytes (bytes) to cap per-upload size |
| Min free disk | 524288000 (500MB) — uploads rejected with 507 if free space drops below this; override via Storage:MinimumFreeDiskBytes |
| Presigned URL default expiry | 3600 seconds (1 hour) — configurable in Settings → Presigned URLs |
| Presigned URL max expiry | 604800 seconds (7 days) — configurable in Settings → Presigned URLs; hard cap enforced server-side |
| Storage quota (global default) | unlimited — configurable in Settings → Storage Quotas; applies to User role only; Admin/Manager unlimited by default |
| Storage quota (per-user) | unlimited — override per user on Users page; applies to any role when explicitly set |
| Prometheus metrics | false — set Metrics:Enabled=true to expose /metrics on the UI port; set Metrics:Token to require it as a Bearer token (Prometheus bearer_token), otherwise the endpoint is open |
| Admin username | admin |
| Admin email | admin@objex.local |
| Admin password | admin |
| Seed buckets | (none) |
| Seed S3 credential | (none) |
Override via appsettings.json or environment variables:
{
"ConnectionStrings": {
"DefaultConnection": "Data Source=/opt/objex/data/db/objex.db"
},
"Storage": {
"BasePath": "/opt/objex/data/blobs"
},
"DefaultAdmin": {
"Username": "myadmin",
"Email": "admin@example.com",
"Password": "changeme"
}
}
⚠️ Change default admin credentials before exposing the instance publicly.
ObjeX decides which API a request belongs to by the port it arrived on, never by the Host header, so any hostname and any public port work in front of it. Two things the proxy must do:
- Pass the
Hostheader through unchanged. S3 clients include it in the signature; a rewrittenHostfails withSignatureDoesNotMatch. - Forward the client address and scheme in
X-Forwarded-For/X-Forwarded-Proto, and tell ObjeX which proxies to trust:
{
"ReverseProxy": {
"Enabled": true,
"KnownProxies": ["10.0.0.5"],
"KnownNetworks": ["172.16.0.0/12"]
}
}As environment variables: ReverseProxy__Enabled=true, ReverseProxy__KnownNetworks__0=172.16.0.0/12. Loopback is always trusted once enabled. While disabled, forwarded headers are ignored and the proxy's own address is logged as the client.
ObjeX can pre-create buckets and an S3 credential on startup so integrations work without manual UI setup. Both are idempotent (skipped if already exists). See docker-compose.yml for a full example with comments.
| Setting | Env var | Description |
|---|---|---|
| Seed buckets | Seed__Buckets |
Comma-separated list, owned by admin |
| S3 access key | Seed__S3Credential__AccessKeyId |
You choose the key |
| S3 secret key | Seed__S3Credential__SecretAccessKey |
You choose the secret |
| S3 credential name | Seed__S3Credential__Name |
Display name (default: seed-credential) |
See SECURITY.md for vulnerability reporting and encryption guidance.
Login: 5 failed attempts lock the account for 5 minutes (per account, configurable via Auth:Lockout); Admin and Manager can lift a lock early with the Unlock button on the Users page. There is no IP-based limiting by design — behind CGNAT or a shared proxy one IP is many users. Hangfire dashboard restricted to Admin role. Security headers set on all responses (X-Content-Type-Options, X-Frame-Options, Referrer-Policy, HSTS).
Blobs use content-addressable hashed paths — the physical filename is a SHA256 hash of "{bucketName}/{key}", spread across a 2-level directory tree:
/data/
├── blobs/
│ └── {bucket}/
│ └── {L1}/ # first 2 chars of SHA256 hash
│ └── {L2}/ # next 2 chars of SHA256 hash
│ └── {hash}.blob
└── objex.db # SQLite — metadata + identity + Hangfire jobs
The logical key (e.g. images/2024/photo.jpg) lives in the database only.
See ROADMAP.md.
Build + test gate on push to main and all PRs — restore → build Release → run xUnit test suite.
On push to main: builds multi-arch image (amd64/arm64) and pushes to GitHub Container Registry automatically.
Published at ghcr.io/centrolabs/objex:
docker pull ghcr.io/centrolabs/objex:latest
docker compose up -dWeekly Monday PRs: one grouped PR for all NuGet minor and patch updates, separate PRs for NuGet major updates, one grouped PR for GitHub Actions versions.
113 automated tests (xUnit, ~5 seconds). Integration tests use real SQLite via WebApplicationFactory — no mocks.
dotnet test src/ObjeX.Tests/Integration-tested with: Outline (presigned POST uploads + presigned GET retrieval) and Memos (server-side PUT uploads + presigned GET retrieval) as S3 storage backends.
Benchmarked with MinIO warp — S3 benchmarking tool for GET, PUT, DELETE, STAT, and mixed workloads. Zero errors on mixed benchmark:
warp mixed --host {host}:{port} --access-key {access-key} --secret-key {secret-key} --tls=false --duration 30s --concurrent 4 --obj.size 1KiB- S3 object lifecycle — upload, download, ETag verification, delete, 404 confirmation, range requests, custom metadata
- Bucket CRUD — create, list, head, delete, duplicate detection, non-empty delete rejection
- Multipart upload — initiate, upload parts, complete, download assembled file, abort cleanup
- Auth boundaries — no credentials, invalid key, wrong signature, expired timestamp, presigned URLs (valid + expired)
- Path traversal —
../,..\\, encoded variants; all blobs verified within base path - Storage quotas — per-user limits, global defaults, admin bypass, 507 on exceed
- Audit log — bucket and object mutations write entries
- Batch delete — multiple keys, mixed existing/non-existent
- CopyObject — within bucket, cross-bucket, non-existent source
- Role isolation — User sees only own buckets; Admin/Manager see all (via S3 API and Blazor)
- Cookie auth — login, bad password redirect, logout
- Health endpoints — liveness, readiness
- Security headers — X-Content-Type-Options, X-Frame-Options, Referrer-Policy, no Server header
- S3 compatibility — stub 501 for unimplemented ops, GetBucketLocation, ListMultipartUploads
- Resilience — missing blob file returns error (not 200), concurrent uploads maintain consistent state, ETag integrity verification on corrupt blobs
- Core validators — BucketNameValidator, ObjectKeyValidator, HashingStream, Sha256HashService
| Scenario | Status |
|---|---|
| Disk full during upload | |
| DB locked under concurrent writes | |
| Corrupt blob on read | ✅ Opt-in — send x-objex-verify-integrity: true header to re-hash before streaming |
| Large file upload (500MB+) |
See CONTRIBUTING.md.
- MinIO — patterns reference
- SeaweedFS — simpler distributed architecture
- Garage — Rust-based self-hosted object storage
- AWS S3 API Reference