A self-hosted file transfer service. From one place to another - simple, secure, fast, no limits.
Originally based on dutchcoders/transfer.sh; this repository is now developed and maintained independently.
Pull the published container image and run it with a local data directory:
docker run --rm \
-p 8080:8080 \
-v $(pwd)/data:/data \
ghcr.io/xenofex7/transfer:latestThen, in another shell:
curl --upload-file ./hello.txt http://127.0.0.1:8080/hello.txtThe response body is the download URL. The X-Url-Delete response header
contains the deletion URL - keep both.
Heads-up for the bare quick start: this command starts the container with no authentication - anything that can reach port 8080 can upload, download and delete. It also fires the anonymous instance heartbeat within 30 s. For anything beyond a local kick-the-tyres run, use the docker compose stack below, which wires up htpasswd auth and an external reverse proxy.
The container image bakes in sensible defaults (LISTENER=:8080,
BASEDIR=/data, TEMP_PATH=/tmp, PURGE_DAYS=360,
PURGE_INTERVAL=24); override them via env or CLI flags as needed.
Multi-arch images (linux/amd64, linux/arm64) are published on GHCR with
latest, semver (X.Y.Z, X.Y, X) and per-commit (sha-<short>) tags.
Pin to a specific version in production.
The deployment stack lives in docker-compose.yml.
# 1. Configuration template
cp .env.example .env
$EDITOR .env
# 2. Auth file (seed at least one user; more can be managed in the UI)
htpasswd -B -c htpasswd alice
# 3. Up
docker compose up -dAfter the stack is up, open the admin UI to manage users, inspect uploads, and tweak branding without touching files by hand.
The transfer container exposes port 8080 only inside the compose network.
TLS and the public hostname are expected to be handled by your reverse proxy
of choice (nginx, Caddy, Traefik). Pass standard proxy headers
(X-Forwarded-Host, X-Forwarded-Proto) and set client_max_body_size to at
least the value of MAX_UPLOAD_SIZE.
Browser routes live behind a cookie-based login at /login. After
signing in (password + TOTP if 2FA is enabled) the following pages
become reachable:
| Path | Purpose |
|---|---|
/admin/files |
Browse stored uploads, filter, manually delete |
/admin/settings |
Tagline, contact email, theme, custom logo / favicon, anonymous-heartbeat toggle |
/admin/users |
Add, reset password, delete - read/write to the mounted htpasswd |
/account |
Enable / disable TOTP, manage API tokens, regenerate recovery codes |
Branding uploads land in <BASEDIR>/.branding/; persisted operator
settings live in <BASEDIR>/.settings.json; TOTP secrets, recovery
codes (bcrypt-hashed) and API token records live in
<htpasswd>.meta.json next to the htpasswd file.
- Passwords: bcrypt-hashed in the standard htpasswd file. Used at
the
/loginform and as the password component of HTTP Basic Auth for uploads. - TOTP (recommended): enrol at
/account/2fa/setup; works with any RFC 6238 authenticator app (Google Authenticator, 1Password, Authy, etc.). Ten one-shot recovery codes are issued at enrolment. - API tokens: per-user named tokens for headless clients. Create
them at
/accountand present them as the password in HTTP Basic Auth (curl --user alice:tk_xxx....). Each token is independently revocable and may carry an expiry of 1 to 3650 days. Use these instead of passwords forcurl/ scripts — they are the only credential allowed to bypass TOTP on the API path. - Session cookies:
httpOnly,SameSite=Lax, markedSecurewhenever the request is HTTPS orX-Forwarded-Proto: httpsis set. Sliding 8h idle TTL, capped by a 30d max lifetime; both tunable via--auth-session-ttland--auth-session-max-lifetime.
Set --auth-require-2fa=false if you want to allow password-only
browser sessions (not recommended for publicly reachable instances).
curl --upload-file ./hello.txt https://your-instance.example.com/hello.txtcurl https://your-instance.example.com/<token>/hello.txt -o hello.txtcurl -X DELETE https://your-instance.example.com/<token>/hello.txt/<delete-token>The <delete-token> is returned in the X-Url-Delete response header on
upload.
# Auto-delete after N days (overrides the server default)
curl --upload-file ./hello.txt https://your-instance.example.com/hello.txt \
-H "Max-Days: 7"
# Cap the download count
curl --upload-file ./hello.txt https://your-instance.example.com/hello.txt \
-H "Max-Downloads: 1"Direct download (skip the preview page):
https://your-instance.example.com/get/<token>/hello.txt
Inline (open in browser instead of download):
https://your-instance.example.com/inline/<token>/hello.txt
For shell helpers, encryption, bulk archives and more, see examples.md.
All flags can be set via CLI args or the matching environment variable.
| Flag | Env | Default | Description |
|---|---|---|---|
--listener |
LISTENER |
127.0.0.1:8080 (binary) / :8080 (container) |
Address the HTTP server binds to |
--proxy-path |
PROXY_PATH |
- | Path prefix when behind a reverse proxy |
--proxy-port |
PROXY_PORT |
- | External port of the reverse proxy |
--cors-domains |
CORS_DOMAINS |
- | Comma-separated list of CORS origins |
| Flag | Env | Default | Description |
|---|---|---|---|
--basedir |
BASEDIR |
required (the container image presets it to /data) |
Path to the local storage directory |
--temp-path |
TEMP_PATH |
OS temp dir (/tmp in the container) |
Path used for in-flight uploads |
| Flag | Env | Default | Description |
|---|---|---|---|
--purge-days |
PURGE_DAYS |
360 |
Days after which uploads are purged |
--purge-interval |
PURGE_INTERVAL |
24 |
Hours between purge runs |
--max-upload-size |
MAX_UPLOAD_SIZE |
0 (no limit) |
Per-file limit in KB |
--rate-limit |
RATE_LIMIT |
0 (off) |
Requests per minute |
--random-token-length |
RANDOM_TOKEN_LENGTH |
10 |
URL token length |
| Flag | Env | Description |
|---|---|---|
--http-auth-user / --http-auth-pass |
HTTP_AUTH_USER / HTTP_AUTH_PASS |
Single-user basic auth |
--http-auth-htpasswd |
HTTP_AUTH_HTPASSWD |
Path to a htpasswd file (multi-user) |
--http-auth-ip-whitelist |
HTTP_AUTH_IP_WHITELIST |
CIDRs that may upload without auth |
--auth-require-2fa |
AUTH_REQUIRE_2FA |
Force every user to enrol TOTP before reaching protected routes (default true) |
--auth-session-ttl |
AUTH_SESSION_TTL |
Idle timeout for the browser session cookie (default 8h) |
--auth-session-max-lifetime |
AUTH_SESSION_MAX_LIFETIME |
Hard upper bound on a session cookie regardless of activity (default 720h) |
--ip-whitelist |
IP_WHITELIST |
CIDRs allowed at the connection level |
--ip-blacklist |
IP_BLACKLIST |
CIDRs denied at the connection level |
| Flag | Env | Description |
|---|---|---|
--tagline |
TAGLINE |
Subtitle shown beneath the hostname on the homepage; empty hides it. The admin UI overrides this at runtime. |
--email-contact |
EMAIL_CONTACT |
Address rendered in the "Contact" link |
--web-path |
WEB_PATH |
Override the bundled web frontend directory (development only) |
--log |
LOG |
Log file path (defaults to stderr) |
Best-effort, async, no-retry POSTs for upload, download and delete events.
| Flag | Env | Description |
|---|---|---|
--upload-webhook-url |
UPLOAD_WEBHOOK_URL |
URL that receives JSON events. Empty disables webhooks. |
--webhook-token |
WEBHOOK_TOKEN |
Optional bearer token added as Authorization: Bearer <token> |
Body shape (event is one of upload, download, delete; optional
fields are omitted when empty):
{
"event": "upload",
"filename": "hello.txt",
"content_type": "text/plain",
"size": 123,
"url": "https://your-instance.example.com/<token>/hello.txt",
"delete_url": "https://your-instance.example.com/<token>/hello.txt/<delete-token>",
"user": "alice",
"downloads": 0
}Setting both UMAMI_SCRIPT_URL and UMAMI_WEBSITE_ID injects the
Umami tracker into user-facing pages (/, download views, 404). Admin
pages (/admin/*) never carry the tag. Self-hosted Umami works as
well as umami.is.
| Flag | Env | Description |
|---|---|---|
--umami-script-url |
UMAMI_SCRIPT_URL |
URL to your Umami script.js |
--umami-website-id |
UMAMI_WEBSITE_ID |
Umami site UUID (data-website-id) |
Transfer sends one anonymous heartbeat per day so I can see how many instances are running and which versions are in the wild. The payload is just the running version - no user data, no IP, no upload metadata, no usernames, no request tracking, not even an instance ID. Events go to a privately-hosted Umami instance.
Caveat: the request unavoidably reveals this server's source IP to the Umami host at the HTTP layer (that's how every outbound HTTP call works); IP logging on that Umami is disabled.
To opt out, toggle it off in /admin/settings, or set
UMAMI_HEARTBEAT=off in your .env. The exact JSON the server would
send is exposed at /admin/settings/heartbeat/payload for inspection.
To redirect the beat to your own Umami instead, set
UMAMI_HEARTBEAT_URL and UMAMI_HEARTBEAT_WEBSITE_ID.
Requires Go 1.25+.
git clone git@github.com:xenofex7/transfer.git
cd transfer
go run . --listener 127.0.0.1:8080 --basedir ./tmp/storage --temp-path ./tmpOr, for a production-style binary (the Go module path is kept on
dutchcoders/transfer.sh for compatibility):
go build -tags netgo \
-ldflags "-X github.com/dutchcoders/transfer.sh/cmd.Version=dev -s -w" \
-o transfer ./Run the tests and the linter:
go test -race ./...
golangci-lint run --config .golangci.ymlCI runs both on every push (see .github/workflows/test.yml).
The roadmap and planned work live in ROADMAP.md.
Built on top of the original work by:
- Remco Verhoef & Uvis Grinfelds - original creators of
transfer.sh - Andrea Spacca & Stefan Benten - long-time upstream maintainers
The upstream copyright notice is kept intact and the project ships under the same MIT license.