Self-hosted file storage in Rust.
A web app, a REST API and folder sync over a content-addressed blob store.
Your files, on hardware you own, under the AGPL.
Early, and not yet usable end to end.
Done: content-addressed local blob store with dedup, the node tree with quotas and blob refcounts, and upload, download, listing, trash and restore over REST.
Done too: password accounts with Argon2id, session tokens, and login from the web app, the desktop shell and the CLI, plus the marketing and documentation site in English and French. The file browser uploads, previews, renames and deletes, and renaming doubles as moving: type a path instead of a name and the node lands there.
And: folder sync, with a three-way reconciler that keeps both copies when a file changed on either side, either once or watching the folder as it changes.
Not written: sharing, search, OIDC, the S3 backend, WebDAV locking, and any interface for the sync beyond the command line. Without locking the surface advertises class 1, which macOS Finder and Windows Explorer read as read-only.
api/ Rust: the server, the domain, the migrations
web/ Angular app, shared by the browser and the desktop shell
site/ Angular marketing and documentation site, prerendered
deploy/ Dockerfile, compose file, Helm chart
Postgres 15 or later, and a Rust toolchain matching rust-toolchain.toml.
DATABASE_URL=postgres://localhost/roxycloud JWT_SECRET=dev-secret cargo run -p roxycloud-apiMigrations run on boot. Configuration is environment only:
| Variable | Default | Purpose |
|---|---|---|
DATABASE_URL |
required | Postgres connection string |
JWT_SECRET |
required | HS256 secret used to sign session tokens |
PORT |
3001 |
Listen port |
BLOB_ROOT |
./data |
Local blob store root |
WEB_ROOT |
unset | Directory holding the built web app, served alongside the API |
CORS_ALLOWED_ORIGINS |
empty | Comma-separated origins for the SPA, not needed when WEB_ROOT serves it |
DEFAULT_QUOTA_BYTES |
10 GiB | Quota granted on first write |
SESSION_TTL_SECONDS |
12 h | Session token lifetime |
BLOB_SWEEP_INTERVAL_SECONDS |
1 h | How often orphaned blobs are collected, 0 disables it |
BLOB_GRACE_PERIOD_SECONDS |
24 h | How long an unreferenced blob is kept before collection |
BOOTSTRAP_ADMIN_EMAIL |
unset | Creates the first administrator on an empty database |
BOOTSTRAP_ADMIN_PASSWORD |
unset | Required alongside the email, minimum 12 characters |
The web app compiles two values in, ROXYCLOUD_API_URL and ROXYCLOUD_SOURCE_URL. They default to
a local API and to this repository, and both are overridden at build time. An empty API URL means
the same origin as the page, which is what the image builds with, since the API serving the app is
also the API it talks to:
pnpm --filter @roxycloud/web build --define ROXYCLOUD_API_URL="'https://api.example.com'" --define ROXYCLOUD_SOURCE_URL="'https://git.example.com/roxycloud'"If you deploy a modified RoxyCloud, point the source URL at your fork: the AGPL requires you to offer your users the source of the version they are actually using.
deploy/docker-compose.yml brings up the API, the web app and a Postgres for them, on
http://localhost:3001:
POSTGRES_PASSWORD=... JWT_SECRET=... docker compose -f deploy/docker-compose.yml up -d --buildThe image carries the built web app and serves it from the same origin as the API, so there is no
second deployment and no CORS to configure. Hosting the bundle elsewhere still works: build it with
ROXYCLOUD_API_URL pointing at the API, serve it however you like, and name its origin in
CORS_ALLOWED_ORIGINS.
On Kubernetes, deploy/helm/roxycloud deploys the API against a database you already run, with a
volume for the blobs and an optional ingress. It does not bundle Postgres. It serves the web app,
since the image carries it. deploy/helm/roxycloud/README.md has the values and the reasoning.
helm install roxycloud deploy/helm/roxycloud --set database.url='postgres://roxycloud:password@postgres/roxycloud' --set jwt.secret="$(openssl rand -hex 32)"The image is ghcr.io/ferrlabs/roxycloud-api, published for amd64 and arm64 by the release
workflow, so the chart's default needs no override.
GET /health
POST /v1/auth/login exchange email and password for a session token
GET /v1/auth/me the authenticated account
GET /v1/folders list the root
GET /v1/folders/{*path} list a directory
PUT /v1/files/{*path} upload, creating parent directories
GET /v1/files/{*path} download
DELETE /v1/files/{*path} move to trash
POST /v1/move rename a node, or move it under another directory
GET /v1/search?q= find a node by part of its name
GET /v1/app-passwords the credentials this account has minted
POST /v1/app-passwords mint one, shown once
DELETE /v1/app-passwords/{id} revoke one, taking effect immediately
PUT /v1/auth/password change your own, giving the current one
GET /v1/users every account, with what each is using (admin)
POST /v1/users create one (admin)
POST /v1/users/{id}/disable end its sessions and refuse it at login (admin)
POST /v1/users/{id}/enable let it back in (admin)
PUT /v1/users/{id}/role admin, member or reader (admin)
PUT /v1/users/{id}/quota how many bytes it may hold (admin)
PUT /v1/users/{id}/password reset it without knowing the old one (admin)
GET /v1/shares the links this account has published
POST /v1/shares publish one, its token shown once
DELETE /v1/shares/{id} revoke one, taking effect immediately
GET /v1/public/{token} what is behind a link, and its listing
GET /v1/public/{token}/entries/{*path} the same, for something under it
GET /v1/public/{token}/content download what the link names
GET /v1/public/{token}/content/{*path} download something under it
OPTIONS /dav what the WebDAV surface supports
PROPFIND /dav/{*path} list a collection, Depth 0 or 1
PROPPATCH /dav/{*path} answered, and refused: no dead properties are stored
MKCOL /dav/{*path} create a collection
GET /dav/{*path} download
PUT /dav/{*path} upload, without inventing the collections above it
DELETE /dav/{*path} move to trash
COPY /dav/{*path} copy, sharing the bytes rather than writing them again
MOVE /dav/{*path} move, in one transaction
LOCK /dav/{*path} take or refresh an exclusive write lock
UNLOCK /dav/{*path} release one
GET /v1/trash what the account has deleted
POST /v1/trash/{id}/restore bring it back, with the directories it needs
DELETE /v1/trash/{id} delete it for good, and release its bytes
Every /v1 route except login and /v1/public/* takes Authorization: Bearer <session token>.
Deleting is reversible. DELETE /v1/files/{*path} marks the node and everything under it, credits
the quota and leaves the bytes alone, so GET /v1/trash lists what was deleted and a restore puts it
back where it was, recreating any directory above it that was deleted in the meantime. A name taken
since the delete answers 409 rather than inventing a new one: move the occupant, then restore. What
was deleted separately stays separate, so restoring a file out of a folder someone deleted later
leaves the rest of that folder in the trash, listed on its own. Only a purge releases the blobs,
which is what makes it the one irreversible call, and purging a folder takes everything trashed
under it, including what was deleted before it.
Releasing a blob does not delete it. A background sweep collects blobs nothing points at once they
have been unreferenced for BLOB_GRACE_PERIOD_SECONDS, which is what keeps a delete followed by a
re-upload of the same content from racing the collector: the re-upload finds the blob and adopts it.
The bytes come back to the disk on that schedule, not on the purge.
A WebDAV client stores its credential in plain text more often than not, so it never gets the
account password. POST /v1/app-passwords mints a high-entropy secret, shows it once, and keeps only
a fingerprint of it. The secret authenticates over Basic auth on the WebDAV surface and nowhere else:
account management answers 401 to it, so a stolen credential cannot mint itself a successor. Revoking
takes effect on the next request, and last_used_at says which credentials nothing is using.
/dav speaks WebDAV against the same tree, authenticated by Basic auth with an app password and
nothing else: a session token is answered 401 there. It advertises class 2, which is what macOS
Finder and Windows Explorer require before they will write to a mounted drive.
Locks are exclusive write locks, taken on a file or on a collection with Depth: infinity. A write
without the token answers 423, whoever is asking, and that includes deleting a folder around a file
someone else holds. A lock lasts ten minutes by default and an hour at most, so a client that
disappears stops holding a file when its lock lapses rather than when someone notices. A COPY shares the blob rather than storing
the bytes twice, and quota is charged for the copy because the tree grew.
Each account carries a role: admin, member or reader. A reader may list and download; upload
and delete answer 403. The check sits in the API rather than in the interface, so it holds for curl
and for roxy sync as much as for the web app.
A share link hands a file or a folder to somebody who has no account. POST /v1/shares takes a
path, mints a 256-bit token, shows it once and stores only a fingerprint of it, so a stolen database
row is not a working link. The token is the whole credential, and it names one node: paths under
/v1/public/{token} are walked downward from that node by following children, so no path a visitor
can write reaches anything the link does not cover.
A link can carry an expiry and a password. The password is chosen by a person rather than generated,
so it goes through argon2 rather than a fingerprint, and it travels in an X-Share-Password header
rather than in the URL that already carries the token. Six characters is enough for it, where an
account password needs twelve: a share password is only ever guessed online against the limiter
below, while an account password has to survive an offline attack on a stolen database.
Revoking is immediate, and so is everything else that should take a link down: the file going to the
trash, the account that published it being disabled, the expiry passing. All of them answer 404,
including a wrong password on a link that does not exist, because a link that says "wrong password"
tells whoever guessed a token that they guessed it.
Publishing is a write. A reader may download every file in the account and still gets 403 from
POST /v1/shares, because handing bytes to anyone holding a URL is not reading them. Revoking is
not, so a member demoted to reader keeps the ability to take down what they published. An anonymous
listing carries names, sizes and modification times and no identifiers: not the node ids, not the
account behind the link, and every public response says Cache-Control: no-store so that a proxy
cannot go on serving a link somebody revoked.
GET /v1/search?q= matches part of a name against the account's own live tree, case-insensitively,
prefix matches first. A result carries the path it was found at, because a name on its own tells you
that you have a file called notes.md without telling you which of the four it is. What was typed is
a literal: % and _ are escaped rather than passed to the pattern matcher, so searching for %
finds files with a percent sign in the name instead of returning everything. The trash is not
searched, and limit and offset page through the results, capped at 200 at a time.
This is names only. Searching inside documents needs text extraction per format, and that is a different feature rather than a bigger version of this one.
Guessing is limited wherever somebody who is not logged in gets to try an answer, which means
POST /v1/auth/login and the password on a share link. Ten attempts cost nothing, the tenth buys a
minute of silence, and each one after that doubles it up to an hour, so a day of guessing buys a
couple of dozen tries. A block runs out and the next attempt is answered on its merits, so this is a
ladder rather than a lockout. The refusal is a 429 with a Retry-After, and it comes before the
password is hashed rather than after, because a guess that costs the server an argon2 is a guess
worth making. Counting and deciding are serialised per subject, so a burst of simultaneous guesses
does not all read the same count and all get through.
Only a guess counts. Opening a password-protected link without sending a password is answered 401 without touching the counter, since that 401 is how a client learns to ask.
The count belongs to what is being guessed, not to where the guess came from: an address for login, the token's fingerprint for a link. Changing address does not shed it, and an address nobody has is counted like one somebody does, so a 429 never answers whether an account exists. Getting it right clears the count. Two things follow: someone who knows an address can keep that account locked out by failing against it (#91), and the counts live in Postgres, so restarting the server does not clear them.
An administrator creates the rest of the accounts, sets their roles and quotas, and can reset a password without knowing it. Disabling one takes effect on the account's next request rather than when its token expires, because every request loads the account behind the session rather than taking the token's word for it. An administrator cannot disable or demote themselves, since an installation nobody can administer is not a state worth being able to reach through the API.
On an empty database, set BOOTSTRAP_ADMIN_EMAIL and BOOTSTRAP_ADMIN_PASSWORD for the first boot
to create the administrator, then log in:
cargo run -p roxycloud-cli -- login you@example.com --password '...'roxy sync reconciles a local folder with the server once and prints what it did. It compares
content, not timestamps: a file is only transferred when its bytes differ from the other side.
ROXYCLOUD_TOKEN=... cargo run -p roxycloud-cli -- sync ~/RoxyCloudState lives in .roxycloud-sync.json inside the folder, which is what makes a second run cheap and
what tells a deletion apart from a file that was never there. Delete it to start from a full
comparison again.
When a file changed on both sides, both copies are kept: the server's version keeps the name, and
the local one is renamed name (conflict <timestamp>).ext and uploaded under that name. Nothing is
overwritten and nothing waits for an answer.
--watch keeps it running instead, syncing as the folder changes:
ROXYCLOUD_TOKEN=... cargo run -p roxycloud-cli -- sync ~/RoxyCloud --watchA save is not a sync. Changes are collected until the folder has been quiet for a moment, and a folder that never goes quiet still syncs at a ceiling rather than waiting forever. Editors that write a temp file, rename it, and touch the directory therefore produce one sync, not four. Ctrl+C stops it.
One thing it deliberately does not do: an empty local directory is not created on the server, since there is no endpoint for that yet.
Removing a folder locally removes it on the server, contents first and the folder itself last. It holds back when the server's copy has gained anything the last sync did not see, a file added from another machine or an edit to one that is already there, because the delete would take that with it. The folder stays, the new work comes down, and the next removal is the user's to make with both sides in front of them.
pnpm install && pnpm run build
cargo fmt --all && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspaceThe tests that need Postgres skip themselves when DATABASE_URL is unset, so the line above runs
anywhere. Point it at a database and they run:
DATABASE_URL=postgres://roxy:roxy@localhost:5432/roxycloud cargo test --workspacepnpm run build builds both browser surfaces. web/dist is embedded in the desktop build, so
build the web app before touching app/. On Linux the Tauri crate needs libwebkit2gtk-4.1-dev,
libappindicator3-dev, librsvg2-dev and patchelf.
The site is a separate Angular app under site/, prerendered to static files in site/dist, with
pnpm run dev:site for the dev server. It carries the install and API pages, so a change to a
config key or an endpoint updates site/src/app/content/ in the same pull request.
See CONTRIBUTING.md.