Small S3-compatible object storage with a built-in admin dashboard.
MonoBucket packages a C++20 storage server and a Svelte dashboard into one binary. It runs without sidecars or an external database.
- An S3 API that works with AWS CLI, boto3, and rclone
- Multipart uploads, presigned URLs, bucket policies, CORS, and SigV4
- A browser dashboard for buckets, objects, uploads, metrics, and settings
- Console sign-in with a password, separate from the S3 keys you issue in it
- Named user accounts with three roles, and an audit log of what they did
- One container for
linux/amd64andlinux/arm64
MonoBucket keeps the person and the program apart:
- User accounts — a username and password — sign in to the dashboard. Each is stored as a PBKDF2-SHA256 verifier, never as a password. The first one is provisioned from the environment; the rest are created in the console.
- S3 access keys sign S3 requests. Issue, rotate and revoke them from the console's Access keys screen. They do not grant console access, and the console session does not sign S3 requests.
Revoking a key never locks you out of the dashboard; changing your password never breaks a client.
There is no default administrator password. A deployment with none configured and none provisioned refuses to start and says what to set — a shipped default would be a published credential on every install.
Every account holds one of three roles. The role decides what it may do in the console and what any S3 access key it issues may do — a key acts as the person who issued it and can never exceed them. Which buckets it may do it in is a second, narrower setting; see below.
administrator |
operator |
readonly |
|
|---|---|---|---|
| Read buckets and objects | ✓ | ✓ | ✓ |
| Write buckets and objects | ✓ | ✓ | |
| Read the resolved settings | ✓ | ✓ | ✓ |
| Issue, rotate and revoke access keys | ✓ | own only | |
| Manage users | ✓ | ||
| Read the audit log | ✓ |
readonly deliberately cannot issue an access key at all, not even a read-only
one: a role whose name says it only reads should not mint a credential that
outlives the session it was minted from.
Enforcement is in the backend. Every console route names the permission it
needs and answers 403 without it, and every signed S3 request is checked
against the role of the identity behind its access key. The console hides what
you cannot use, which is a courtesy — calling the API directly gets the same
refusal.
A role is instance-wide, so on its own it is all-or-nothing: an operator who
may write to one bucket may write to every bucket. Each account therefore also
carries a bucket access setting, chosen when the user is created and
changeable from the users page afterwards.
| Reads the bucket and its objects | Writes them, and the bucket's settings | |
|---|---|---|
write |
✓ | ✓ |
read |
✓ | |
none |
It is one default plus a list of exceptions, which is what lets one setting
express both of the things people ask for. "Sam works on reports and nothing
else" is a none default with reports set to write. "Everybody except the
backups bucket" is a write default with that one bucket set to none. A
bucket created later falls to the default.
Three things about it are worth knowing before you rely on it:
- It narrows a role and never widens it. The two are ANDed and the narrower
answer wins, so a
readonlyaccount grantedwriteaccess to a bucket still only reads it. - It applies to S3 access keys too, because a key acts as the person who issued it. Narrowing an account narrows every key it owns, on the next request.
- An administrator is never narrowed. An administrator can change their own grants, so enforcing them would be a lock whose key hangs on the door — and the one failure worse than an unenforced rule is one that strands the only account able to repair it. The console refuses to store a narrowing for an administrator rather than keeping one it ignores.
A bucket an account cannot see is left out of the bucket list — in the console
and in ListBuckets — rather than making the whole listing a 403. Anonymous
access to a public bucket is unaffected: a bucket policy is a statement about
clients carrying no credentials, and this is a statement about ones that do.
Disabling an account ends every console session it holds immediately and stops its S3 access keys on their next request; nothing is cached between requests. The keys survive, so re-enabling restores them. Deleting an account revokes them instead. The last enabled administrator cannot be deleted, disabled or demoted, so the console cannot be locked out of itself.
Activity records sign-ins, sign-outs and refusals (each with the source address), user and credential changes, bucket creation, deletion and every change to who can reach one, console object uploads and deletes, and every denied authorisation check. Access and policy entries name the resulting visibility, so "who made this public, and when" is answered outright. S3 signature failures are deliberately excluded — they need no credential, so logging them would let anyone who can open a socket fill the ring. It is a bounded ring of the most recent 20000 entries kept beside the metadata — a window on what just happened, not an archive. Ship the server's stdout somewhere durable if you need one.
Create a password first. Twelve characters minimum; a file keeps it out of
docker inspect and /proc/<pid>/environ.
umask 077
head -c 24 /dev/urandom | base64 > admin_password.txtdocker run --rm \
-p 9000:9000 \
-p 9001:9001 \
-v "$PWD/admin_password.txt:/run/secrets/admin_password:ro" \
-e MONOBUCKET_ADMIN_PASSWORD_FILE=/run/secrets/admin_password \
-e MONOBUCKET_ROOT_ACCESS_KEY=monobucket \
-e MONOBUCKET_ROOT_SECRET_KEY=change-me-please \
-v monobucket-data:/data \
ghcr.io/sinhaparth5/monobucket:2026.08.0The S3 API listens on localhost:9000. The dashboard is
at localhost:9001 — sign in as admin with that
password, then issue an S3 key from Access keys.
AWS_ACCESS_KEY_ID=<the issued key id> \
AWS_SECRET_ACCESS_KEY=<the secret, shown once> \
aws --endpoint-url http://localhost:9000 s3 lsThe account is provisioned once. After the first start the verifier lives in the data volume, so you can drop the password variable entirely and the server still comes up. Leaving it set means every restart resets the password back to that value — which is how you recover a lost one, and not what you want otherwise.
MONOBUCKET_ADMIN_USERNAME chooses the name (default admin), and
MONOBUCKET_ADMIN_PASSWORD is the inline alternative to the file. Setting both
forms is an error rather than a silent precedence rule.
docker-compose.yml wires the password in as a Compose
secret. Create secrets/admin_password.txt, then docker compose up. See
secrets/README.md.
export MONOBUCKET_DATA_DIR=/var/lib/monobucket
export MONOBUCKET_ADMIN_PASSWORD_FILE=/etc/monobucket/admin_password
export MONOBUCKET_ROOT_SECRET_KEY=...
monobucketUnder systemd, LoadCredential= puts the password in the unit's private
credential directory and MONOBUCKET_ADMIN_PASSWORD_FILE points at it. Keep
the file 0600 and owned by the service user.
If the console is served over HTTPS — directly or behind a proxy that sets
X-Forwarded-Proto — the session cookie is marked Secure automatically.
MONOBUCKET_CONSOLE_COOKIE_SECURE forces the question either way; forcing it
on behind a plain-HTTP hop makes the browser accept the cookie and then refuse
to send it, which looks like a login that silently does nothing.
The full list of settings is in .env.example.
Console sign-in used to take the root S3 access key and secret. It no longer does, and no S3 key of any kind signs you in.
- Before upgrading, pick an administrator password and put it in
MONOBUCKET_ADMIN_PASSWORD_FILE(orMONOBUCKET_ADMIN_PASSWORD). Without one, the upgraded server will not start — deliberately, so the change cannot be discovered as a console that silently accepts nothing. - Start it. The account is provisioned on first boot and logged by name. Sign in with the username and password.
- Existing S3 clients keep working untouched.
MONOBUCKET_ROOT_ACCESS_KEYandMONOBUCKET_ROOT_SECRET_KEYstill sign S3 requests exactly as before. Nothing about the S3 API changed. - Optionally, move clients off the root pair. Issue a key per client from Access keys, swap it in, and confirm. Issued keys can be rotated and revoked individually; the root pair can only be changed by restarting the server, which is why it is best kept as a break-glass credential.
Nothing is migrated automatically and nothing is removed. Step 4 can happen whenever, or never.
The single administrator account became one of many user accounts with roles. Nothing has to be done by hand:
- Start it. The old administrator record is converted in place into a user
with the
administratorrole, keeping its username, password and creation date, and the old record is dropped. Access keys issued before this release are adopted into that account, so they keep working and become revocable by name. - Add the people who need access under Users, giving each the narrowest role that does their job. They sign in with their own username and password.
- Reissue keys per identity if you want a program's authority bounded — a
key issued by an
operatorcannot manage users, and one issued by areadonlyaccount cannot write.
The root S3 pair is untouched and still administrator-equivalent. Downgrading to
2026.08.1 after this will not find the migrated account, because it reads the
record this release deleted; set MONOBUCKET_ADMIN_PASSWORD on that start to
re-establish one.
A backup is a checkpoint: a consistent, startable copy of the whole data directory, taken while the server keeps serving. Payloads are hard-linked, so on the same filesystem it duplicates no bytes and finishes in about the time it takes to flush the write-ahead log.
From the console (Settings → Backup), which is the only way to back up a running instance — one process may hold the metadata store at a time, so the server is the only thing that can copy its own. It is off until you say where backups may go:
MONOBUCKET_BACKUP_DIR=/backups
That is a directory, and the name you type in the console is created inside it.
Names may not contain a path separator: the destination arrives over HTTP, and a
console session is not a shell on the host. The action needs the backup:write
permission, which only an administrator holds — a checkpoint copies every S3
secret in the instance.
From the shell, against a stopped server:
monobucket --checkpoint /backups/2026-08-20
Verify one before you rely on it. A backup nobody has checked is a belief, not a backup:
MONOBUCKET_DATA_DIR=/backups/2026-08-20 monobucket --fsck --deep
Restore is: stop the server, point MONOBUCKET_DATA_DIR at the checkpoint
(or copy it back over the data directory), start. There is no import step,
because a checkpoint is a data directory.
Three things worth knowing:
- Nothing is scheduled. MonoBucket writes a copy when somebody asks and never on a timer. Retention, rotation and off-site copies are what a backup tool is for, and half of one built into the server would be the half nobody tests. Drive it from cron, a systemd timer, or whatever already runs your backups.
- Writes during a checkpoint are not blocked, and are simply not in the copy. A checkpoint is a coherent instant, not a pause. Reclamation is held off for the duration, so a delete landing mid-copy cannot strip a payload the snapshot still names; the deferred payloads are collected by the next pass.
- A checkpoint taken under load may carry a few stray payloads. An upload
whose bytes reached the tree before the payload pass but whose metadata was
committed after the snapshot leaves a file the copy references from nowhere.
--fsckreports those asunreferenced-payload, so a busy instance's backup exits 2 rather than "clean" while a quiet one exits 0. That is the safe direction and the only one on offer: the alternative to an extra file is a missing one. What is never present is a payload the copy needs and lacks. - A hard link is not an off-site copy. A checkpoint on the same filesystem shares inodes with the live store and dies with the disk. It protects against a bad delete, a bad migration, and a bad afternoon — not against hardware. Copy it somewhere else afterwards.
Every project has them; these are MonoBucket's, and they are worth reading before you rely on it:
- Concurrent single-PUT uploads are not memory-bounded. Peak resident memory
tracks concurrency × object size — four simultaneous 32 MiB PUTs reach about
151 MiB. MonoBucket's own write path streams the body into the payload tree in
fixed-size chunks; the residency is below it, in Drogon, which spills a body
past
MONOBUCKET_MAX_MEMORY_BODY_BYTESto a temporary file and then hands the handler a memory map of the whole file. The pages are file-backed and evictable rather than anonymous, so this is a weaker hazard than holding the object on the heap, but it is resident. Multipart uploads are unaffected — each part is bounded by the client's part size, which is why the AWS CLI and rclone moving large objects stay flat. Fixing it means adopting Drogon's request-streaming API. - A backup is a point-in-time copy, taken on request. There is no continuous replication, no incremental or differential mode, and no built-in schedule or retention. Each checkpoint is a full, independent copy of the store — cheap in bytes when it hard-links, but a separate set of directory entries every time.
CopyObjectis not implemented and answers 501 rather than silently storing the header's value as an object.- No
io_uringbackend. Blocking I/O runs on a bounded thread pool. Under the workloads measured so far the request path, not the I/O pool, is the limit. - No TLS to Redis.
rediss://is refused at startup with the reason; terminate TLS in front of Redis or use a private network. - S3 secrets are stored recoverable, not hashed. SigV4 is a symmetric HMAC scheme: verifying a signature means re-deriving the signing key from the secret, so there is no verifier that authenticates a request without reproducing the secret. Anyone who can read the data directory can read every issued S3 secret. Console passwords are not stored this way — those are PBKDF2-SHA256 verifiers, and a stolen data directory does not yield a login.
- The upload limit is instance-wide only. One maximum object size applies to every bucket and every client; there is no per-bucket or per-user limit, and no precedence rule to learn. A bucket's allocation bounds how much it holds, not how large one object in it may be.
- Bucket policies are a small, closed grammar, not IAM. What is evaluated:
Allowstatements grantings3:GetObjectors3:ListBuckettoPrincipal "*"over a whole bucket (arn:aws:s3:::<bucket>,arn:aws:s3:::<bucket>/*, or*). That is the entire language. A document containing anything else — aDeny, aCondition, aNotAction, a named principal, an action other than those two, or a resource scoped to a key prefix — is refused atPutBucketPolicy, naming the element, rather than stored and ignored. Signed requests never consult the policy at all: they are authorised by the role of the access key's owner. A policy is therefore only ever a statement about clients carrying no credentials. - Checksums are verified, but not every shape of them. The
x-amz-checksum-*family is checked onPutObjectandUploadPart— CRC32, CRC32C, CRC64NVME, SHA1 and SHA256, sent as a header or as anaws-chunkedtrailer — and a value that does not match the bytes received is refused withBadDigest. Three gaps remain. A multipart object always gets the composite checksum (a checksum of the parts' checksums,-Nsuffixed);x-amz-checksum-type: FULL_OBJECTis refused atCreateMultipartUploadrather than answered with a composite the client did not ask for. Per-part<ChecksumCRC32>elements inside aCompleteMultipartUploadmanifest are not compared — each part's ETag already pins it, and the composite is checked against the completion's ownx-amz-checksum-*header when one is sent. AndListObjectsV2does not report a per-objectChecksumAlgorithm. An algorithm this build cannot compute is refused rather than stored unchecked: a checksum accepted and discarded is indistinguishable, to the client, from one that was verified. - Authorisation is per identity, not per key. An account can now be narrowed to particular buckets — read, write, or not at all, set when the user is created and changed from the users page — and a signed request is held to the grants of the account that owns its access key. What does not exist is anything narrower than the account: every key its owner holds reaches the same buckets with the same authority, so giving one program read access and another write access to the same bucket means two accounts. Nor is anything narrower than a bucket: there is no key-prefix scoping, so an account that may delete an object in a bucket may delete any object in it. An administrator is not narrowed at all, deliberately — see Users and roles above.
- The root S3 key pair cannot be revoked from the console. It comes from the environment, is not attached to any user account, and is administrator- equivalent. Treat it as a break-glass credential and issue per-client keys for everything else; changing it means restarting with different values.
- The audit log is a bounded ring, not an archive. The most recent 20000 entries, oldest dropped on write, stored beside the object metadata and lost with the data directory. Anything that has to be kept belongs wherever the server's stdout is collected. Entries are written without an fsync, so a power cut can lose the last few.
- A role or bucket-access change reaches an open console tab only by ending its session. The session carries a copy of both, so changing a user's role, status or bucket grants signs them out rather than silently updating the page. S3 access keys need no such step — the owner's record is read on every signed request, so a narrowing takes effect on the next one.
- Storage allocations are logical, not physical, and are enforced in one
process. A bucket's allocation counts the object bytes it holds; it does not
count the copy a multipart completion makes while concatenating parts, the
RocksDB metadata beside them, or a payload still in
tmp/. The allocatable capacity carries a 10% reserve for exactly that, and the reserve is a heuristic rather than a measurement. The tally itself is in memory, rebuilt from the objects at startup — correct for the single writer MonoBucket is, and not a scheme that would survive a second process writing the same directory. - An allocation is not a reservation on disk. Allocating 500 GiB to a bucket
reserves nothing from the filesystem; it only caps what that bucket may store.
Allocating more in total than the disk holds is refused, but a disk shared with
anything else can still fill under MonoBucket without any bucket reaching its
allocation. Set
MONOBUCKET_ALLOCATABLE_BYTESwhen the data directory is not alone on its volume. ListMultipartUploadsignoresdelimiter. The parameter is echoed back but no keys are rolled up, so the response carries noCommonPrefixes.prefix,key-markerandupload-id-markerall work, so a listing is complete and resumable — it is only the delimiter rollup that is missing.- Object versioning, lifecycle rules, server-side encryption and IAM-style
policies beyond the credentials described above do not exist. Abandoned
multipart uploads are expired on a timer
(
MONOBUCKET_MULTIPART_EXPIRY_HOURS), which is the one piece of lifecycle behaviour that does exist, because without it they leak.
Three deliberate deviations from S3, all refused at write time rather than
stored as data nothing can later address: object keys containing control
characters; keys containing a path traversal segment; and a Cache-Control,
Content-Disposition, Content-Encoding, Content-Language or Expires
header containing a control character or longer than 1024 bytes. Those five are
stored with the object and returned on every read, so a CR that got stored once
would split every response for that key from then on.
MonoBucket is licensed under GPL-3.0-or-later.