Easy and fast file sharing from the command line — and a clean web UI when you need it.
You have a file. Someone else needs it. You want a URL you can paste into chat, drop into a CI log, or curl down from a production box five minutes later — then have it disappear. send.to does exactly that, and nothing else.
curl --upload-file ./build.tar.gz https://send.to/build.tar.gz
# → https://send.to/aB3cD4eF/build.tar.gz
curl https://send.to/aB3cD4eF/build.tar.gz -o build.tar.gzOne static Go binary. One 52 MB Docker image. No database. No account. Your files.
- Features
- Quick start
- Usage
- The
sendCLI - HTTP API
- Configuration
- Deployment
- Architecture
- Benchmarks
- FAQ
- Examples
- Contributing
- License
| Single static binary | CGO_ENABLED=0, runs on scratch as non-root UID 10001 |
| Pluggable storage | local filesystem, S3 (Minio / DO Spaces, and anything S3-compatible) |
| End-to-end encryption | AES-256-GCM on the client; the key rides in the URL fragment and never reaches the server |
| Server-side encryption | OpenPGP AES-256 via X-Encrypt-Password header |
| Client-friendly | Works with curl, wget, HTTPie, PowerShell, any HTTP client |
| Resumable uploads | Chunked sessions: a 5 GB transfer that dies at 90% continues where it stopped |
| Collections | One link for several files, with a landing page and a download-all archive |
| Accountless history | An owner token lists and deletes your uploads from any machine |
| Auto-expiry | Per-file Max-Days / Max-Downloads headers + scheduled purge |
| Virus scanning | Optional ClamAV prescan and VirusTotal submission |
| TLS | Bring-your-own cert, or automatic via Let's Encrypt |
| Auth | HTTP Basic, htpasswd, IP allow/deny lists |
| Hardened | Strict CSP, COOP/CORP, HSTS, slow-loris timeouts, constant-time auth compare, per-IP rate limiting |
| Graceful shutdown | SIGINT/SIGTERM → Shutdown(ctx) → in-flight uploads complete |
| JSON API | Accept: application/json returns url, delete url, size, expiry |
| Observability | public /health (JSON), plus /metrics on a loopback-bound internal listener |
| Modern web UI | Astro 5 + React 19 + Tailwind 4: multi-file queue, paste-to-upload, per-file ETA, retry, expiry / download-limit / password controls, QR codes, and a local upload history that keeps your delete links |
Pick whichever fits your setup. All three produce the same running service on http://localhost:18080.
Requires only Docker.
git clone https://github.com/sooua/send.to && cd send.to
./scripts/docker.sh upThat's it. The wrapper script:
- Copies
.env.example→.envif missing (edit to customise). - Runs
docker compose up -d --build. - Probes
/health.htmlfor up to 30 s and prints the URL once ready.
| Command | What it does |
|---|---|
./scripts/docker.sh up |
Build & start in background |
./scripts/docker.sh down |
Stop & remove containers (volume preserved) |
./scripts/docker.sh purge |
Stop & delete the data volume (asks first) |
./scripts/docker.sh logs |
Follow container logs |
The image is ≈ 52 MB, non-root, read-only root filesystem, no shell, with a built-in HEALTHCHECK.
Requires Go 1.25+ and Node 22+.
Linux / macOS / WSL
./scripts/deploy.sh # foreground, Ctrl+C for graceful shutdown
./scripts/deploy.sh --daemon # background; logs in build/sendto.log
./scripts/deploy.sh --stop # stop a daemonised instance
PORT=9000 ./scripts/deploy.sh # override portWindows (PowerShell)
.\scripts\deploy.ps1 # foreground
.\scripts\deploy.ps1 -Daemon # background
.\scripts\deploy.ps1 -Stop # stop
$env:PORT="9000"; .\scripts\deploy.ps1Everything lives under ./build/ and ./data/ — uninstall is rm -rf build data web/dist web/node_modules.
make build # Go binary + web bundle
./send.to --provider local --basedir ./data --web-path ./web/distOr run make dev to get the Go server and the Astro dev server hot-reloading in parallel.
# Plain upload
curl --upload-file ./notes.md https://send.to/notes.md
# Encrypted upload (server stores ciphertext)
curl -H "X-Encrypt-Password: s3cret" \
--upload-file ./notes.md https://send.to/notes.md
# Limit to 5 downloads, expire in 7 days
curl -H "Max-Downloads: 5" -H "Max-Days: 7" \
--upload-file ./notes.md https://send.to/notes.md
# Multipart: many files in one request
curl -F file1=@a.txt -F file2=@b.txt https://send.to/curl https://send.to/<token>/notes.md -o notes.md
# Decrypt on the fly
curl -H "X-Decrypt-Password: s3cret" \
https://send.to/<token>/notes.md -o notes.md
# Resumable (Range requests)
curl -C - -o big.iso https://send.to/<token>/big.iso# Combine multiple stored files into one stream
curl https://send.to/(tokenA/a.txt,tokenB/b.txt).zip -o bundle.zip
curl https://send.to/(tokenA/a.txt,tokenB/b.txt).tar.gz -o bundle.tgzThe upload response includes an X-Url-Delete header — hit it with DELETE.
curl -X DELETE https://send.to/<token>/notes.md/<deletion-token>More recipes — fish/PowerShell aliases, encrypted database backups, CI snippets — are in examples.md.
Drop this in your ~/.bashrc / ~/.zshrc:
send() { curl --progress-bar --upload-file "$1" "https://send.to/$(basename "$1")"; }
# then: send ./report.pdfA shell alias gets you a URL. The client gets you a URL you can find again.
send config add home https://send.to --default # once
send put report.pdf # every day aftersend put ./build.tar.gz --days 7 --max-downloads 3
send get https://send.to/aB3cD4eF/build.tar.gz # resumes if interrupted
send info https://send.to/aB3cD4eF/build.tar.gz # limits, without spending one
send ls # what this machine uploaded
send rm https://send.to/aB3cD4eF/build.tar.gz # uses the stored delete link
| Command | What it does |
|---|---|
send put <file>... |
Upload; - reads stdin (--name required) |
send get <url> |
Download, resuming a partial file automatically |
send info <url> |
Size and remaining limits — HEAD, so it costs no download |
send ls |
Local upload history: links, expiry, delete links |
send rm <url> |
Delete, using the deletion link recorded at upload time |
send config |
Named server profiles, --default, basic auth |
Flags work wherever you put them: send put a.txt --days 7 does what it looks
like, rather than silently uploading with no expiry.
The history lives in send config path (~/.config/sendto on Linux). Losing
a link no longer means losing the ability to delete the file — the one thing a
curl alias can never do for you.
SENDTO_URL, SENDTO_USER and SENDTO_PASS override the config file, so CI
needs no state on disk.
--e2e encrypts on your machine before anything is sent. The key is generated
locally and travels in the link's fragment — the part after #, which
browsers, proxies and access logs never transmit. The server receives
ciphertext and has no way to read it.
send put contract.pdf --e2e
# https://send.to/aB3cD4eF/contract.pdf#k=9qbxPpQRFJ0xmdYqZ4qh73BppXc3UzK26wyvyXPQxFs
# └── the key. Lose it and the file is gone.
send get 'https://send.to/aB3cD4eF/contract.pdf#k=9qbx…' # decrypts locallyThe web UI offers the same thing as a checkbox, and a recipient opening the link in a browser decrypts it there — the key never leaves the tab.
AES-256-GCM in 64 KiB chunks, with the chunk counter and a final-chunk marker
folded into each nonce, so reordering or truncating the stream is an
authentication failure rather than a silently shorter file. The Go and browser
implementations are checked against each other by
test/e2e-interop/run.sh.
This is not the same as --encrypt / X-Encrypt-Password, which is
server-side: convenient, works with plain curl, but the server sees the
plaintext on the way through. Use --e2e when the server itself is not
trusted.
There is no recovery. Nobody — not the operator, not you — can decrypt a file whose fragment was lost.
| Method | Path | Purpose |
|---|---|---|
PUT |
/{filename} |
Upload a single file |
POST |
/ |
Multipart upload (one or many) |
POST |
/upload/{filename} |
Start a resumable upload |
PATCH |
/upload/{id}/{filename} |
Send one chunk of a resumable upload |
HEAD |
/upload/{id}/{filename} |
How many bytes the server holds |
DELETE |
/upload/{id}/{filename} |
Abandon a resumable upload |
GET |
/{token}/{filename} |
Download or preview |
HEAD |
/{token}/{filename} |
Metadata only |
POST |
/collection |
Group existing uploads behind one link |
GET |
/c/{token} |
A collection: page, JSON, or one URL per line |
GET |
/c/{token}.{zip,tar,tar.gz} |
The whole collection as one archive |
GET |
/({files}).{zip,tar,tar.gz} |
Combine files into an archive |
DELETE |
/{token}/{filename}/{delToken} |
Delete a file |
PUT |
/{filename}/scan |
ClamAV scan (auth + rate limited) |
PUT |
/{filename}/virustotal |
VirusTotal submission (auth + rate limited) |
GET |
/owner/files |
List this owner token's uploads |
GET |
/health · /health.html |
Health check (JSON on Accept: application/json) |
GET |
/metrics |
Prometheus counters — internal listener only, see below |
GET |
/qr?url= |
QR code PNG for a share link on this host |
| Request header | Purpose |
|---|---|
Max-Days |
Auto-expire after N days |
Max-Downloads |
Cap downloads at N |
X-Owner-Token |
Record the upload in this owner's server-side list |
X-Encrypt-Password |
Server encrypts payload (OpenPGP AES-256) |
X-Decrypt-Password |
Provide password on download |
If-None-Match |
Skip the transfer if you already hold this ETag |
Accept-Language |
Language for error messages (en, zh, ja) |
| Response header | Meaning |
|---|---|
X-Url-Delete |
Authenticated URL to DELETE this upload |
X-Remaining-Days |
Days until auto-expiry |
X-Remaining-Downloads |
Downloads remaining under the cap |
ETag |
Validator for the stored bytes; stable for the upload's life |
Cache-Control |
no-store unless the instance opted into caching |
Content-Language |
Language the error body was written in |
A PUT is all or nothing: lose the link at 90% of a 5 GB artefact and all of it
is gone. A resumable upload is a session plus chunks, and a chunk is the only
thing a failure can cost.
size=$(stat -c%s build.tar.gz)
# Open a session; the URL comes back in Location.
session=$(curl -sS -D- -o /dev/null -X POST -H "Upload-Length: $size" -H "Max-Days: 7" https://send.to/upload/build.tar.gz | awk '/^[Ll]ocation:/{print $2}' | tr -d '
')
# Send a chunk. 204 means "keep going", and Upload-Offset says from where.
curl -sS -X PATCH -H "Content-Range: bytes 0-8388607/$size" --data-binary @chunk-0 "$session"
# Ask where to resume after anything goes wrong.
curl -sS -I "$session" | grep -i upload-offset
# The chunk that completes the file answers exactly like a plain PUT:
# the share URL, with the delete link in X-Url-Delete.
curl -sS -X PATCH -H "Content-Range: bytes 8388608-$((size - 1))/$size" --data-binary @chunk-1 "$session"Rules worth knowing:
- A chunk is all or nothing. A body that arrives short is discarded and the offset stays where it was, so the offset you resume from is always one you chose.
- Sending the wrong offset answers
409 ConflictwithUpload-Offsetset to the right one, rather than corrupting the file. - Bytes are spooled under
TEMP_PATHand only reach the storage backend when the upload completes — an interrupted upload is never a visible half file. - Sessions expire after 24 hours and take their spool file with them.
X-Encrypt-Passwordis rejected here: the server would have to keep it in clear between chunks. Encrypt on the client instead (send put --e2e).
send put uses this automatically for files of 16 MiB and up, records the
session, and continues it on the next run — including for --e2e, where the
ciphertext is regenerated from the offset the server stopped at:
$ send put build.tar.gz
^C
$ send put build.tar.gz
resuming at 1.4 GB of 5.0 GB
Against a server without these endpoints the client falls back to a plain PUT.
Uploading five log files gives you five links, and the fifth one is the one that gets lost in the chat. A collection is one link that lists them all:
send put ./*.log --collection --collection-name "nightly logs"
# https://send.to/c/aB3cD4eFOpening it in a browser shows the file list with a "download all" button;
curl gets one share URL per line, and Accept: application/json gets the
whole record. Everything at once is the same link with an extension:
curl -sL https://send.to/c/aB3cD4eF.zip -o logs.zip # .tar and .tar.gz too
curl -s https://send.to/c/aB3cD4eF | xargs -n1 curl -O # or fetch them one by oneA collection can also be assembled from uploads that already exist, which is what the CLI does under the hood:
curl -X POST -H "Content-Type: application/json" \
-d '{"name":"nightly logs","files":["https://send.to/aB3/app.log","https://send.to/cD4/worker.log"]}' \
https://send.to/collectionIt owns no bytes of its own:
- Deleting the collection leaves the files alone — somebody may hold one of their links directly. Each file keeps its own delete link and its own limits.
- A member that expires, runs out of downloads or is deleted simply drops off the list, and a collection with nothing left answers 404.
- Downloading the archive charges each member one download, exactly as fetching them individually would.
- At most 100 files per collection.
--collectionand--e2edo not combine: each file has its own key, and one link cannot carry several of them without handing the server the means to read the files.
The send CLI keeps a local record of what it uploaded, which is no help at all
when the machine that uploaded it was a CI runner that no longer exists — the
delete links went with it.
Send an owner token and the server keeps the list instead:
curl -H "X-Owner-Token: $SENDTO_OWNER_TOKEN" --upload-file build.log https://send.to/build.log
curl -H "X-Owner-Token: $SENDTO_OWNER_TOKEN" https://send.to/owner/files
# one share URL per line, or the full records with Accept: application/jsonThere is still no account:
- The server stores sha256 of the token and never the token itself. It is an index name, not a credential it could hand to anyone else.
- Holding the token is the whole of the authorisation, so treat it as a password: it lists share links and delete links for every upload made with it.
- Entries disappear when their upload does — deleted, expired, or out of downloads — and the list prunes itself when it is read.
- Uploads made without the header stay anonymous, exactly as before.
- At most 200 entries per token are kept; the oldest fall off.
send derives one token per server from a master secret in its config
directory (owner.key, mode 0600), so a server you upload to learns nothing it
could replay against a different one:
send put build.log # the token rides along
send ls --remote # what this owner has on the server, from any machine
send rm https://send.to/aB3cD4eF/build.log # uses the delete link the server keptSet SENDTO_OWNER_TOKEN to share one identity across machines — several CI
runners publishing into the same list, for instance.
Send Accept: application/json on an upload to get structured output instead of
a bare URL — no more scraping the X-Url-Delete header:
curl -H "Accept: application/json" -H "Max-Days: 7" --upload-file notes.md https://send.to/notes.md{
"url": "https://send.to/aB3cD4eF/notes.md",
"delete_url": "https://send.to/aB3cD4eF/notes.md/9xK…",
"filename": "notes.md",
"size": 4096,
"content_type": "text/x-markdown",
"encrypted": false,
"expires_at": "2026-07-30T08:06:49Z"
}A multipart POST returns {"files": [ … ]} with one object per file.
Max-Downloads counts completed transfers only:
- Range requests never count.
curl -C -, video scrubbing and byte-range probes can resume a file freely without eating the budget. - A transfer that fails or is aborted mid-body does not count.
- When the budget or
Max-Daysruns out the object is deleted from storage immediately, rather than waiting for--purge-days.
An upload never changes once stored, so every download carries an ETag and
answers 304 Not Modified to anyone that sends it back in If-None-Match.
That costs an operator nothing and is always on — a client holding the file
pays for neither the body nor the storage read.
Whether a cache may keep the response is a different question, and the answer
is no by default. --cache-max-age turns it on:
CACHE_MAX_AGE=600 ./send.to --provider local --basedir /data
Even then, only uploads where caching cannot change behaviour become
public, max-age=…, immutable:
Max-Downloadsuploads never become cacheable. The budget counts completed downloads, and a cached copy is served without the origin knowing it happened.- Server-side encrypted uploads never become cacheable. They are decrypted
per request against
X-Decrypt-Password, and a cache mishandling that would hand one visitor's plaintext to another. - The lifetime is clamped to the file's own
Max-Days, so a cached copy cannot outlive the link. - Errors — including 404s — are always
no-store, so a cache cannot keep a transient failure in place of the file.
The cost you accept in exchange: a deleted file stays reachable through the
cache for up to CACHE_MAX_AGE, and downloads served by the cache do not
appear in /metrics. Pick the value with that in mind; ten minutes buys most
of the benefit at a fraction of the exposure.
Error bodies follow Accept-Language, in English, Chinese and Japanese — the
same three the web UI ships. A request without the header gets English, so
existing scripts see no change. Errors that carry internal detail (storage
failures, scanner problems) stay in English on purpose: they are for whoever
reads the logs.
A live reference is rendered in the web UI at /api-docs.
Every CLI flag has an environment-variable equivalent (--listener ↔ LISTENER). Run ./send.to --help for the full matrix. The most relevant knobs:
| Flag / Env | Default | Notes |
|---|---|---|
--listener / LISTENER |
:18080 |
Plain HTTP bind address |
--tls-listener / TLS_LISTENER |
empty | Enable native HTTPS |
--provider / PROVIDER |
— | local | s3 |
--basedir / BASEDIR |
— | Storage root for the local provider |
--max-upload-size |
0 |
KB per upload; 0 = unlimited |
--max-storage-size |
0 |
KB an instance will hold in total; 0 = unlimited |
--max-temp-size |
0 |
KB of spool space for uploads in progress; 0 = unlimited |
--per-ip-upload-quota |
0 |
KB / hour / IP an uploader may spend; 0 = unlimited |
--cache-max-age |
0 |
Seconds a browser or CDN may keep a download; 0 = never cache |
--rate-limit |
0 |
Requests / minute / IP, shared across all routes |
--temp-path / TEMP_PATH |
system temp | Spool dir for uploads; must be disk-backed |
--purge-days |
0 |
Delete uploads older than N days (all four providers) |
--shutdown-timeout |
30s |
Grace period for in-flight requests on quit |
--http-auth-user / _pass |
empty | HTTP Basic Auth credentials |
--cors-domains |
empty | Comma-separated Allow-Origin list |
--clamav-host |
empty | e.g. tcp://clamav:3310 |
--virustotal-key |
empty | Enables /{file}/virustotal |
--lets-encrypt-hosts |
empty | Comma-separated hostnames for ACME |
MAX_UPLOAD_SIZE bounds one upload, which is no bound at all: the same
permitted file uploaded often enough fills the disk. Two totals close that.
MAX_STORAGE_SIZE |
Total bytes (KB) the storage backend may hold |
MAX_TEMP_SIZE |
Total bytes (KB) of spool space for uploads in progress |
Both answer 507 Insufficient Storage rather than failing halfway. A resumable upload is checked twice — once against its declared total when the session is opened, so a doomed transfer is refused before a byte moves, and again on each chunk, because several sessions can be opened before any of them fills anything.
MAX_TEMP_SIZE is the one that matters against abuse: an unfinished session is
kept for 24 hours by design, so without it anyone can open sessions, send almost
all of each, and leave the bytes sitting on TEMP_PATH — which in the shipped
compose file is the data volume.
Neither total says anything about who filled the instance. One client can
reach MAX_STORAGE_SIZE on its own and leave every other user with a 507, and
RATE_LIMIT does not help: sixty requests a minute is sixty large files a
minute.
PER_IP_UPLOAD_QUOTA |
Kilobytes per hour one source address may upload |
It answers 429 Too Many Requests, because unlike the totals it refills — as
a token bucket, continuously, not by resetting on the hour. The declared total
of a resumable upload is charged when the session is opened and not refunded if
the session is abandoned, so opening sessions and walking away is not a way
around it. Keep it above MAX_UPLOAD_SIZE; below it, an upload at the permitted
maximum can never succeed, which the server warns about at startup.
The source address comes from X-Forwarded-For/X-Real-IP only when the peer
is loopback or a private range, so a direct client cannot spoof itself a fresh
budget. Behind a proxy that does not set those headers, every upload shares one
bucket.
Two properties to know before relying on them:
MAX_STORAGE_SIZEis a counter, not a measurement. It is seeded from the backend at startup and then tracked in memory, because measuring means listing the bucket. It drifts if files are removed behind the server's back, and a restart re-seeds it. Both shipped backends can be counted; a backend that could not would make the instance refuse to start rather than pretend a limit is in force.- All of them are per process. Several replicas behind a load balancer each enforce their own total, and each keeps its own per-IP buckets in memory, so a client spread across N replicas gets N budgets. Resumable uploads are single-instance for the same reason: the session spool lives on local disk, so a session opened on one replica cannot be continued on another. Use one instance, or a sticky-session balancer with a shared spool volume.
/metrics is not on the public listener. It sits on a second server bound
to 127.0.0.1:6060 by default, together with pprof when --profiler is set:
--profile-listener / PROFILE_LISTENER |
Bind address of the internal listener |
--profiler / PROFILER |
Also mount /debug/pprof/ there |
The counters are not secrets one by one, but together they tell a stranger how
full the instance is and how often its limits are biting, which is
reconnaissance for whoever is causing that. /health stays public, because a
load balancer probes it from outside.
The two flags are independent. --profile-listener used to imply --profiler,
from when the listener carried nothing else; moving the address off loopback so
a scraper can reach it must not also publish heap dumps of upload contents.
In Docker the default is unreachable from another container. Set
PROFILE_LISTENER=0.0.0.0:6060, put the scraper on the same network, and do
not publish that port.
/metrics exposes sendto_storage_used_bytes, sendto_storage_limit_bytes,
sendto_temp_used_bytes and sendto_temp_limit_bytes, which is what to alert
on — a 507 is the point at which it is already too late.
The docker-compose.yml + .env.example combo documents everything you need for container deployments.
Two production patterns:
1. Reverse proxy (most common) — keep send.to on plain HTTP and let Caddy / Nginx / Traefik / Cloudflare terminate TLS:
files.example.com {
reverse_proxy 127.0.0.1:18080
}Make sure the proxy forwards X-Forwarded-Proto: https so the HSTS / CSP headers kick in.
2. Native TLS
./send.to --tls-listener :8443 \
--tls-cert-file fullchain.pem --tls-private-key privkey.pemOr automatic Let's Encrypt:
./send.to --lets-encrypt-hosts files.example.com- Set
MAX_UPLOAD_SIZEto a sane value — protects disk / S3 bill. - Set
RATE_LIMIT— protects against a single abusive IP. - Always front with HTTPS (browsers refuse
clipboard.writeTexton insecure origins). - Enable Basic Auth on instances that must not accept anonymous uploads.
- Mount the storage volume on a partition with a quota.
- Monitor the
HEALTHCHECKstatus (docker ps) or poll/health. - Scrape
/metricson the internal listener (uploads, downloads, bytes, 429s, expired purges). - Keep
TEMP_PATHon a disk-backed path — uploads without aContent-Length, and every upload when the ClamAV prescan is on, are spooled there first. The compose file points it at the data volume because the container's/tmpis a small tmpfs.
git pull
./scripts/docker.sh up # rebuilds & rolls the container
# or:
./scripts/deploy.sh --stop && ./scripts/deploy.sh --daemonGraceful shutdown is bounded by SHUTDOWN_TIMEOUT, so in-flight uploads up to that deadline complete before the old instance exits.
┌──────────────┐ HTTPS ┌────────────────┐
│ Browser / │ ─────────────────▶ │ Reverse Proxy │
│ curl / │ │ (optional) │
│ HTTPie │ ◀───────────────── │ │
└──────────────┘ └────────┬───────┘
│ HTTP
▼
┌────────────────┐
│ send.to │
│ Go binary │
│ │
│ • mux router │
│ • rate limit │
│ • CSP / HSTS │
│ • OpenPGP enc │
│ • ClamAV / │
│ VirusTotal │
└────────┬───────┘
│
┌──────────────┴──────────────┐
▼ ▼
local FS S3 / Minio
.
├── cmd/ CLI flag parsing (urfave/cli)
├── server/ HTTP handlers, auth, security, storage backends
│ └── storage/ local | s3
├── internal/clamd/ Embedded ClamAV daemon client
├── web/ Astro 5 + React 19 frontend
├── scripts/ deploy.sh · deploy.ps1 · docker.sh (one-click ops)
├── test/ End-to-end smoke test + Windows signal helper
├── Dockerfile 3-stage build: web bundle → Go binary → scratch
├── docker-compose.yml Production-ready compose (healthcheck, read-only FS)
├── Makefile build · test · lint · vuln · smoke · docker
└── .github/workflows/ CI: Go test+race+coverage, lint, govulncheck, web build
Approximate numbers from a single-core laptop run (Windows 11, WSL2 off, local filesystem backend, empty pre-warmed cache):
| Metric | Value |
|---|---|
| Cold-start time | < 50 ms |
| Image size | 51.8 MB |
| RSS at idle | ~ 20 MB |
| Upload throughput (local FS) | line-rate (~ disk) |
| Concurrent uploads (10×) | all succeed, zero errors, distinct tokens |
| Graceful shutdown overhead | bounded by SHUTDOWN_TIMEOUT (default 30 s) |
A self-contained smoke test exercising 22 assertions (health, security headers, round-trip, encryption, limits, concurrency, graceful shutdown) is in test/smoke/. Run it locally with make smoke.
Is there a size limit?
No hard-coded limit. Set --max-upload-size (KB) to bound uploads. With no limit the only cap is disk space on the storage backend.
How long are files kept?
Clients choose per upload via the Max-Days and Max-Downloads headers. Operators set a server-wide floor with --purge-days. Reach the cap → file is deleted on next access or next purge run.
Are files encrypted at rest?
Only if the uploader sends X-Encrypt-Password (OpenPGP AES-256). Otherwise files are stored as-is. On the S3 backend, use the bucket's own server-side encryption in addition.
Can I run it behind a reverse proxy on a sub-path?
Yes. Set --proxy-path /send (or PROXY_PATH=/send) and point the proxy at the container. All URLs in responses will be rewritten accordingly.
How do I back up uploads?
For local provider: everything is in --basedir. Snapshot that directory or use filesystem-level tools (rsync, ZFS snapshots, etc.). For S3, use the bucket's replication / versioning features.
Does it work offline / on an air-gapped network?
Yes. The binary ships with no runtime deps, the image has none, and the web UI is a fully static Astro build. Only use of the network is outbound to the configured storage backend (and optional ClamAV / VirusTotal endpoints).
See CONTRIBUTING.md for dev setup, build / test commands, and PR conventions.
For security issues, please follow SECURITY.md — don't open public issues for vulnerabilities.
Third-party license attributions are consolidated in THIRD_PARTY_LICENSES.md.