Your own drive, in one binary. Browse, upload, share, and search your files from a browser — with no configuration, no database server, and no lock-in.
- One file to run. Download the binary, run it, open the URL it prints. That is the install.
- Your files stay files. They live at
users/<name>/at the paths you gave them. Open them over SSH, back them up withrsync, edit them from another program — the drive notices and catches up. - Nothing is stored in a private format. The SQLite index is a disposable cache of what is on disk. Delete it and it rebuilds.
- Only "delete forever" destroys anything. Overwrites and deletes go through trash first.
- HTTPS by typing your domain name. One variable, a real Let's Encrypt certificate, auto-renewed.
Grab the binary for your architecture from the latest
release — drive-linux-amd64 or
drive-linux-arm64, checksums in SHA256SUMS — make it executable, and run it:
chmod +x drive-linux-amd64 && mv drive-linux-amd64 drive
./driveThat is the whole setup. It picks a data directory, creates it, starts on :8080, and prints a
one-time URL for creating your account:
WARN serving plaintext HTTP on an address other than localhost: anyone on this network can
read passwords, files, and session cookies address=[::]:8080
fix="put it behind a proxy that terminates TLS, or set DRIVE_HOSTNAME to a public name"
INFO drive started version=v0.1.0 address=[::]:8080 encrypted=false data_dir=/home/you/.local/share/drive
WARN no account exists yet: open this URL to create the first administrator
url="http://localhost:8080/setup?token=cQgFFHLQk87H96Np97C09UqaloejjZMkEXYVFPl3tsg"
Open it, pick a username and password, and you are in. The token works once, survives restarts until you use it, and the setup page stops existing the moment the first administrator exists.
About that warning. The default listener accepts from the whole network in plaintext. Turn on HTTPS below, or for a quick local try-out silence it with
DRIVE_ADDR=127.0.0.1:8080.
Name the drive and it gets a real certificate from Let's Encrypt and renews it:
DRIVE_HOSTNAME=drive.example.com DRIVE_ACME_EMAIL=you@example.com ./driveThe name must already point at the machine, and the CA must be able to reach it from the internet
on port 80 or 443 to check that you own it. The listen address becomes :443 on its own, and the
certificate is obtained before the port opens — a drive that cannot get one fails at startup with
the reason instead of serving broken handshakes.
Leave DRIVE_HOSTNAME unset and it serves plaintext and generates no certificate of any kind. It
will never sign one for itself: a browser warning on first run is not security. Plaintext is the
right answer behind a proxy that terminates TLS, on a Tailscale address, or on loopback.
No clone and no toolchain: one file is the whole deployment.
curl -O https://raw.githubusercontent.com/fosslife/drive/master/compose.yml
podman compose up -d # docker compose works too, the file is the same
podman compose logs drive # the setup URL is printed hereOne service, one volume, one port. The image is ghcr.io/fosslife/drive, published for amd64 and
arm64 on every release tag, and it contains the same static binary and nothing else — no runtime, no
database server, no web server. Upgrading is podman compose pull && podman compose up -d.
Settings go in a .env beside it — copy .env.example, uncomment what you want, and
up -d again. Every variable in it is already defaulted, so there is nothing to write until you want
something changed, and compose ignores the file if it is not there.
The setup URL in the log says localhost unless DRIVE_HOSTNAME tells it otherwise, because
nothing else tells a container the name you reach it by. Keep the token, swap the host.
Images and binaries are signed as they are built, so you can check that what you pulled came out of
this repository before you run it: gh attestation verify oci://ghcr.io/fosslife/drive:latest --repo fosslife/drive. See docs/verify.md.
Building the image yourself, and why the file is named Dockerfile
podman build -t ghcr.io/fosslife/drive:latest .Tagged as the published image, compose.yml runs that instead of pulling — the tag is the only
thing the two paths share. down -v is not how you pick up a new build: it deletes your data volume
and leaves the image untouched.
The build file is called Dockerfile rather than Containerfile because that is the name every
tool agrees on: Compose only ever looks for Dockerfile, while podman build accepts it as a
fallback. The podman-native name works with podman build and breaks podman compose.
Both stages build for the machine you run them on and cross-compile for the target, so the two release architectures are two native builds rather than one of them emulated.
Select a folder, press Share, send the link. Whoever opens it gets that one folder and nothing above or beside it — no account, no sign-in, read-only. Revoke it and it stops working immediately.
The first account is the administrator, created by the setup link above. It gets an Accounts screen the others do not: hand out an account, disable one, reset a forgotten password, set a storage limit, and see what each person is holding alongside the drive's own free space.
Everyone, administrator included, has their own files at their own paths. Administering an account never opens what is inside it — there is no screen, and no endpoint, that reads another account's files. Deleting an account removes the login and leaves the files on disk, so re-creating it with the same name gives them back.
Administrator is not a role you can hand out: the instance keeps the one it was set up with. A storage limit refuses the next upload and nothing else — it never deletes or hides what is already there, and it cannot hold back files you put into the drive over SSH.
There is no config file and there will never be one. Every value has a working default; the environment overrides it.
| Variable | Default | What it does |
|---|---|---|
DRIVE_DATA_DIR |
$XDG_DATA_HOME/drive, else ~/.local/share/drive, else /var/lib/drive |
Everything that survives a restart |
DRIVE_ADDR |
:8080, or :443 with a hostname set |
Listen address |
DRIVE_HOSTNAME |
unset | Public DNS name. Setting it is the only switch for HTTPS |
DRIVE_ACME_EMAIL |
unset | Where the CA sends expiry warnings |
DRIVE_ACME_DIRECTORY |
Let's Encrypt | Point at a staging or private CA |
DRIVE_MIN_FREE_BYTES |
1073741824 (1 GiB) |
Headroom kept free; writes are refused below it |
DRIVE_DEFAULT_QUOTA_BYTES |
0 (no limit) |
Storage limit a newly created account starts with |
DRIVE_SCAN_INTERVAL |
15m |
How often the index catches up with the disk |
DRIVE_UPLOAD_RETENTION |
24h |
How long an interrupted upload waits to be resumed |
DRIVE_TRASH_RETENTION |
720h (30 days) |
How long deleted files stay recoverable. 0 means forever |
Back up the whole data directory, not just users/.
index.db file metadata, rebuildable by scanning — and accounts, which are not
certs/acme/ certificates, only when DRIVE_HOSTNAME is set
users/<username>/ your files, at their real paths
.drive/{tmp,trash,thumbs}
File metadata is rebuildable by scanning, but accounts, API tokens, and share links live only in
index.db and are gone with it. That is a deliberate trade: losing the index can never cost you a
byte of file content, and re-creating an account with its original username reattaches it to its
files.
scripts/backup.sh backup <data-dir> <backup-dir>That does it consistently while the drive is running, including the SQLite online backup. The full procedure, and how to restore from files alone, is in docs/backup.md.
Uploads are resumable and chunked, which trips the default body-size limit on some proxies — nginx refuses at 1 MiB out of the box. Caddy needs nothing. See docs/proxies.md.
Developing
Nothing here needs rebuilding an image to see a change, and there is no separate dev compose file because there is nothing for it to do. The interface is an ordinary client of the API, in development exactly as in production, so the two halves are worked on independently.
Changing the interface. Leave the container running and put Vite in front of it:
podman compose up -d # the API, on :8080
npm --prefix web run dev # the interface, on :5173, with hot reloadOpen http://localhost:5173. Vite serves the app from source and proxies /api to whatever is
listening on :8080 — the container, or a binary you ran yourself, it does not care. Edits appear
immediately; no image build, no restart, no dist/.
Changing the Go side. This is the part a Next.js habit expects to be hard, and it is not:
go run ./cmd/drive # a second or two, then it is servingStop it, run it again. A watcher would save you the keystroke and cost a dependency; the compile is
already faster than a container restart. Run it on :8080 and the Vite proxy above finds it with no
configuration. Build the image only to check the image; releases build their own.
Building from source
Go lives wherever you put it; there is no cgo and no C toolchain.
npm --prefix web ci && npm --prefix web run build # the interface, embedded by web/embed.go
go build -ldflags "-X main.Version=v0.1.0" -o drive ./cmd/drivego build does not build the interface — it is a separate step, and a binary missing it serves the
whole API and answers / with a note saying so. scripts/release.sh does both and cross-compiles
for linux amd64 and arm64.
The screenshots above are captured from the real binary in a real browser:
node web/e2e/shots.mjs docs/screenshots 1440Design notes
- Nothing but permanent delete destroys your bytes. Overwrites and deletes go to trash first.
- Writes are atomic: temp file, fsync, rename, fsync the parent directory. An interrupted upload cannot be observed as a half-written file.
- The reconciler has no delete path. A file that vanishes from disk is marked missing, never purged.
- Stored files are served as attachments with
nosniffand a restrictive CSP. Inline rendering is limited to inert raster images — never SVG, never PDF. - Accounts are isolated: one storage root each, no shared folders, no groups. See BACKLOG.md for what was deliberately left out and why.


