Instant file sharing from one workerd process and one S3 bucket, on a box you run.
There are two things you can do — upload and download — and the whole tool is built around only those two. Uploading gets you a PIN; anyone with the PIN and the password can take the thing back out, in a browser or in a terminal. Nothing lists what is stored, anywhere. The only number either front end reports is how much room is left.
A pin holds exactly one thing. Upload a file and the pin holds that file. Upload a folder and it is zipped on your machine first, so the pin holds one zip — and downloading gives you that zip straight back.
dropbin files.example.com
----------------------------------------------------------------------
1 upload a file or a folder
2 download with a pin
----------------------------------------------------------------------
[#.....................] 9.71 GB free of 10.0 GB 6 pin(s) stored
1 or 2 to choose, q to quit
You need two things: an S3 bucket and a key that can read and write it, and
somewhere to run workerd. Any S3 server works —
Garage, SeaweedFS, MinIO, AWS — as long as it
answers path-style requests (ENDPOINT/bucket/key), which they all do.
npm install # workerd, the only dependency
export ACCESS_PASSWORD='something long'
export S3_ENDPOINT=http://127.0.0.1:3900 S3_BUCKET=dropbin S3_REGION=garage
export S3_KEY_ID=… S3_SECRET=…
npm start # workerd serve workerd.capnp → :8787workerd.capnp is the whole deployment: the modules, the
bindings, the port. Secrets and the endpoint come from the environment, so the
file holds none. Put it behind anything that terminates TLS — a reverse proxy,
a tunnel — and pass X-Forwarded-Proto: https: the socket trusts that header,
so the links dropbin builds, and the clients it hands out, say https://.
Running it as a service is a systemd unit with EnvironmentFile= and
ExecStart=workerd serve /path/to/workerd.capnp.
dropbin fetches nothing but the S3 endpoint, and the config only lets it reach
that: its outbound network allows public and private addresses because the
store is usually on the LAN. Tighten storage to the store's address if you
like.
changeme until you set ACCESS_PASSWORD — that fallback is in the code. If
curl -s -o /dev/null -w '%{http_code}' -H "X-Pass: changeme" https://host/?info=1
says 200, nobody set it.
Changing it invalidates the dp cookie in every browser and every outstanding
db open / db qr link, since those are signed with the password as the key.
It gates everything except the two clients (/cli, /cli.ps1), the two
installers (/install.sh, /install.ps1), /robots.txt and the favicon.
Accepted four ways: ?p=, an X-Pass: header, HTTP basic auth, or the dp
cookie the web login sets (one year).
QUOTA_GB in workerd.capnp is what the meter counts down from — 10 GB by
default. It is a display figure, not an enforced cap; set the same number as a
quota on the bucket if it has to hold.
Anyone who uses this more than once should install the tool. One command, then
db is on the path — pick whichever line suits the machine:
curl -fsSL https://files.example.com/install.sh | sh # macOS, Linux, WSL
npm i -g dropbin # anywhere npm isirm https://files.example.com/install.ps1 | iex # Windowsdb # the menuAll three land the same thing: a zero-dependency Node package (18 or newer) that
lives in cli/, installing db and dropbin as a longer alias. The
Worker only advertises it; nothing about the tool runs on the server.
The installers are the shorter way in — no npm, no registry account, one command that fetches the release from GitHub. They need Node 18+ to already be there; the client below is the one that needs nothing at all. Where things land:
| files | db |
|
|---|---|---|
install.sh |
~/.local/share/dropbin |
~/.local/bin |
install.ps1 |
%LOCALAPPDATA%\dropbin\app |
%LOCALAPPDATA%\dropbin\bin, added to your PATH |
Fetched from a deployment, an installer bakes that deployment into the shim it
writes, so the db it leaves behind already points at your host — DROP_HOST
still overrides it. Fetched from GitHub it bakes in nothing:
curl -fsSL https://raw.githubusercontent.com/dharunashokkumar/dropbin/main/install.sh | shBoth take the same handful of options — a version to pin, where to put things, and how to undo it:
curl -fsSL https://files.example.com/install.sh | sh -s -- --version 1.1.0
curl -fsSL https://files.example.com/install.sh | sh -s -- --uninstall
sh install.sh --help# iex cannot pass arguments, so hand it a script block instead
& ([scriptblock]::Create((irm https://files.example.com/install.ps1))) -Version 1.1.0
& ([scriptblock]::Create((irm https://files.example.com/install.ps1))) -UninstallReleases are on GitHub,
two archives holding the same files: dropbin.tgz for install.sh (and for
npm i -g ./dropbin.tgz) and dropbin.zip for install.ps1, which unpacks with
Expand-Archive. --tarball and -Archive install one you already have, which
is how a machine with no way out to GitHub gets db.
db up ./holiday.png # a file, random pin
db up ./holiday.png photos # a file, your own pin
db up ./project # a folder — zipped here, then sent
db get 4821 # save it in this folder
db get 4821 ~/Downloads # save it there
db view 4821 # print it here, if it is text
db qr 4821 # a code to point a phone at
db open 4821 # look at it in a browser
db rm 4821 # throw the pin away
db free # how much room is leftThe menu offers the same two options the shell client does, and nothing else:
dropbin files.example.com
--------------------------------------------------------------
[1] Upload a file or folder
[2] Download with a pin
[q] Quit
Storage: 9.71 GB free of 10.00 GB · 6 pins stored
Choice:
The password is asked for on every run and stored nowhere — there is no config
file and no token cache. DROP_PASS=... skips the prompt for scripts and CI,
DROP_HOST=... (or --host) points db at another deployment. After an upload
the link goes to the clipboard unless you pass --no-copy. Folders are zipped
with node:zlib, so zip, tar and Compress-Archive do not have to exist.
Downloads never overwrite: a second copy of holiday.png becomes
holiday.png.1. db up and db get print the one useful line — the link, the
saved path — on stdout and everything else on stderr, so they pipe cleanly.
For a one-off, or a machine without Node: the Worker hands you a shell script, already pointed at your host.
curl -s https://files.example.com/cli -o drop && bash drop
bash <(curl -s https://files.example.com/cli) # or in one lineIt asks for the password, then shows the two options. Press 1 or 2; q
goes back, and q at the top quits.
Upload asks three things and nothing else:
Upload
1 file
2 folder (zipped before it is sent)
q back
file to upload: ./holiday.png
1 random pin (4 digits)
2 custom pin (anything you like)
Uploaded
pin 4821
file [IMG] holiday.png 2.41 MB
url https://files.example.com/4821
Download asks for a pin, tells you what is behind it, and lets you take it or read it:
Download pin 4821
[IMG] holiday.png 2.41 MB 2026-09-08 11:39
1 download into the current directory
2 view in the pager
q back
A folder is packed with zip if it is installed and tar -czf otherwise, so
the pin holds a .zip or a .tgz. Downloads land in the directory you started
from and never overwrite: a second copy becomes name.1. DROP_PASS=… skips
the password prompt, DROP_HOST=… points it at a different deployment.
On Windows PowerShell the same client is at /cli.ps1 (folders go through
Compress-Archive):
irm https://files.example.com/cli.ps1 -OutFile drop.ps1 ; ./drop.ps1Give it arguments and it skips the menu entirely:
./drop up ./holiday.png # a file, random pin
./drop up ./holiday.png photos # a file, your pin
./drop up ./project # a folder — zipped, then sent
./drop get 4821 # save it here
./drop get 4821 ~/Downloads # save it there
./drop view 4821 # print it
./drop rm 4821 # throw the pin away
./drop free # how much room is leftexport PASS=changeme
curl -T ./holiday.png "https://:$PASS@files.example.com/up/" # upload
curl -OJ "https://files.example.com/4821?p=$PASS" # download
curl "https://files.example.com/4821?info=1&p=$PASS" # what is it?
curl -X DELETE "https://files.example.com/4821?p=$PASS" # throw it away
curl "https://files.example.com/?p=$PASS" # room left
curl -Tonly appends the local filename when the URL ends in/and has no query string — hence thehttps://:$PASS@host/up/form, which sends the password as basic auth instead.-H "X-Pin: photos"picks the pin and-H "X-Name: other.png"picks the stored name. In PowerShell writecurl.exe; barecurlis an alias forInvoke-WebRequest.
Open the domain, type the password once, and you get a grey dialog with two buttons and a storage meter — Upload and Download, and nothing else to click.
Upload asks the same three things as the terminal: file or folder, then a
random 4-digit pin or one you type, then it sends and shows you the pin.
Download is a pin box; type one and you get the file's name, size and date
with Download, View and Close.
It works with JavaScript off, except for folders: a folder is zipped in the browser (STORE, no compression) so the server never holds a folder in memory, and that needs JS. With JS off the folder option is greyed out and single files still upload through a plain form post.
| method | path | what |
|---|---|---|
| GET | / |
the two options and the meter — ?info=1 ?json=1 |
| GET | /PIN |
a browser looks at it, everything else downloads it |
| GET | /PIN?view=1 |
the bytes inline; ?dl=1 saves them; ranges supported |
| GET | /PIN?info=1 |
what the pin holds, without fetching it |
| GET | /PIN?k=EXP.SIG |
a share link: that one pin, read-only, no password |
| PUT | /up |
raw-body upload — X-Name, X-Pin, ?quiet=1 |
| POST | /up |
multipart: one file field + optional pin |
| DELETE | /PIN |
throw the pin away |
| GET | /cli, /cli.ps1 |
the client scripts, pre-pointed at this host |
| GET | /install.sh, /install.ps1 |
the db installers, pre-pointed the same way |
| GET | /upload, /get |
the browser's two dialogs |
?info=1 is the terminal client's only data feed, one tab-separated line:
/?info=1→pinsusedquota, all in bytes/PIN?info=1→namesizedate
db open and db qr hand a link to something that cannot be asked for a
password — a browser, or a phone pointed at a code — so they sign one instead
of just linking it. ?k= is an expiry and an HMAC of the pin, keyed on the
password: nothing is stored anywhere, it opens exactly that one pin, it is
read-only (a DELETE or an upload with it is refused), and it lasts a week.
SHARE_HOURS in workerd.capnp caps how far ahead one may be signed.
A browser landing on a pin gets the file on screen — an image, a video, text,
a PDF — with a Download button it has to be asked for. Uploaded files still
carry Content-Security-Policy: sandbox and nosniff, so an uploaded page
cannot act on the origin while it is being looked at.
- A pin is 4 digits by default — 9000 of them, checked for collisions before
one is handed out, and widened to 6 digits if they ever all fill up. A custom
pin is anything up to 64 characters that is not a slash, a control character
or a reserved word (
up,cli,get,upload,api, …). - Uploading to a pin you have already used replaces what was there. One pin, one thing.
- Files live until you delete them — there is no expiry job.
- An upload is read into memory before it is stored, so the process needs roughly the largest upload in RAM. A proxy in front usually sets the real ceiling first: Cloudflare's tunnel and proxy, for one, cap a request body at 100 MB on the free plan. Downloads stream and cost nothing.
- Folders are zipped by the client and never by the server, which keeps per-byte work — and a whole folder in memory — off the box.
- Uploaded files are served with
Content-Security-Policy: sandboxandnosniff, so an uploaded.htmlor.svgcannot act on this origin. - Pins written by older versions held several files. Those still work: they
come back out as one zip, capped at
ZIP_LIMIT_MBof hashing, and the individual files stay reachable at/PIN/path/to/file. - Local dev:
npm startwith theS3_*variables pointed at a throwaway bucket. There is nothing to build:workerd.capnpembedssrc/as it is. - The
dbtool is a second npm package incli/, published asdropbin. It is not bundled into the Worker — only the two clients and the two installers are, as text with__HOST__swapped for the request origin on the way out. - A release is cut by hand:
node cli/pack.jsbuildsdropbin.tgzanddropbin.zipout of the same files and refuses if the two disagree, andgh release createattaches them. No workflow deploys or publishes anything. - Change anything in this tool and update
CLAUDE.md(and this README) to match.
Read CLAUDE.md first — it is the architecture, and it explains
why most of the obvious features are deliberately absent, what CI checks, and
how to verify a change against npm start (there is no test suite). Deploys,
GitHub releases and npm publishes are run by hand, not by a workflow.
Found a security problem? Do not open an issue —
report it privately.
SECURITY.md also covers the threat model and how to harden a
deployment: set a real password, prefer X-Pass: over ?p=, and put a rate
limit in front of it.
MIT © Dharun Ashokkumar