# transfer.sh: the file sharing server that asks you to bring your own instance

> A Go file sharing server built around a single curl upload, with pluggable storage backends, header driven retention, and a README that opens with an auth bypass advisory.

**dutchcoders/transfer.sh** — Easy and fast file sharing from the command-line.

- Repository: https://github.com/dutchcoders/transfer.sh
- Website: https://github.com/dutchcoders/transfer.sh
- Stars: 15,896 · Forks: 1,582
- Language: Go
- License: MIT
- Published: 2026-10-06 · Updated: 2026-10-06 · Language: en
- Canonical page: https://hysenlabs.com/projects/dutchcoders-transfer-sh

## A curl upload that needs no client at all

The whole interface fits in one line. The README's upload example is:

```bash
$ curl -v --upload-file ./hello.txt https://transfer.sh/hello.txt
```

There is no account, no API key and no multipart form. The file name in the path is the name you want the download to have, and the server answers with the URL it generated. Two aliases reshape that URL without a redirect document: the README shows that /get/ produces a direct download link and /inline/ forces the browser to render the content rather than hand it back as an attachment.

The response headers do the rest of the work. The delete example in the README shows how to recover the deletion URL from a single upload:

```bash
curl -sD - --upload-file ./hello.txt https://transfer.sh/hello.txt | grep -i -E 'transfer\.sh|x-url-delete'
x-url-delete: https://transfer.sh/hello.txt/BAYh0/hello.txt/PDw0NHPcqU
https://transfer.sh/hello.txt/BAYh0/hello.txt
```

So an upload gives you two URLs: the one you hand to a person, and the one you keep. Losing the second means the file stays until it expires. The path shape is also why the URL is guessable if the short key is guessable, which is the argument for not using a public install for anything you mind keeping past its expiry.

One extra endpoint worth noting is the VirusTotal relay, which uploads the payload and hands it off for scanning:

```bash
$ curl -X PUT --upload-file nhgbhhj https://transfer.sh/test.txt/virustotal
```

Deletion, when you still have the header value, is a DELETE request to that URL.

## Client side encryption as a pipeline stage

Rather than asking for a key parameter on upload, the README documents encrypting on the client and piping the ciphertext to the server, so the plaintext never leaves the machine:

```bash
$ gpg --armor --symmetric --output - /tmp/hello.txt | curl --upload-file - https://transfer.sh/test.txt
```

Reading it back is the same shape reversed:

```bash
$ curl https://transfer.sh/1lDau/test.txt | gpg --decrypt --output /tmp/hello.txt
```

The stdin form is the part that generalizes. Anything that writes bytes to stdout becomes a uploader, which covers age, openssl enc, gpg as shown, or a compression step you wanted anyway, and the server has no idea which it was storing.

That would make server side features redundant, except the server does also offer them. The X-Encrypt-Password header exists:

```bash
$ curl --upload-file ./hello.txt https://your-transfersh-instance.tld/hello.txt -H "X-Encrypt-Password: test" # Encrypt the content server side with AES256 using "test" as password
```

The README puts a warning above this section and above the matching decrypt header, both saying to use the feature only on your self hosted server because trusting a third party service for server side encryption is at your own risk. That is accurate and worth restating: server side encryption hides bytes from the storage provider and the network, but not from whoever runs the process holding your password.

The go.mod tells you the crypto side leans on ProtonMail's gopenpgp plus golang.org/x/crypto rather than rolling its own, with a ProtonMail go-crypto dependency pinned in the same block.

## Headers carry the retention and access policy

There is no admin panel and no per user quota model in the README. Instead, the caller states intent per request in headers.

Max-Downloads and Max-Days are the pair that matter most, because they turn a share into something with a deadline:

```bash
$ curl --upload-file ./hello.txt https://transfer.sh/hello.txt -H "Max-Downloads: 1" # Limit the number of downloads
$ curl --upload-file ./hello.txt https://transfer.sh/hello.txt -H "Max-Days: 1" # Set the number of days before deletion
```

A one-shot download link is just a header away, which is a reasonable primitive for handing over a build artifact or a config fragment to one person. Note that the limits are requests the server makes on your behalf, so the operator is still the one holding the data until both conditions are met.

The configuration table at the end of the README is the server side equivalent, and it maps one to one onto environment variables. listener and profile-listener decide ports, force-https and the tls-listener family turn on redirection and TLS, http-auth-user, http-auth-pass and http-auth-htpasswd cover basic auth with either literal credentials or an htpasswd file, http-auth-ip-whitelist lets named addresses upload without an auth challenge, virustotal-key enables the scanning endpoint, and ip-whitelist and ip-blacklist gate connections to the service at all. temp-path chooses where uploads land before a backend moves them, and web-path points at the static web frontend, which the go.mod pulls in as a separate module, github.com/dutchcoders/transfer.sh-web.

That htpasswd entry is not incidental. It arrived in v1.6.0 alongside a fix for a bug where a merge after v1.5.0, including images tagged as edge, had disabled basic auth entirely.

## The advisory above the README title

The first heading in the README is not the project name. It links to issue 670 and reads: IP filter and HTTP auth bypass via unauthenticated X-Forwarded-For header spoofing.

Read that against the dependency list and the mechanism is easy to reconstruct. github.com/tomasen/realip is in go.mod, and it is the library that decides which client address the server believes it is talking to. X-Forwarded-For is a request header the client controls, so if realip is trusted to supply the address without a proxy in front of it actually rewriting that header, then ip-whitelist, ip-blacklist and http-auth-ip-whitelist can all be steered by whoever is making the request. The v1.6.1 release notes describe exactly this class of bug for basic auth: the v1.6.0 fix still allowed any IP to bypass basic auth when an IP whitelist was set, regardless of whether the address matched.

Two v1.6 releases in a row carrying auth bypass fixes tells you something about the surface area. This is a service whose entire access model is a handful of headers, so header handling is the security model, and the recent history of this project is a reminder that this needs testing on every deployment rather than trust in the version tag.

There is also a second, quieter factor. The v1.6.0 notes point at a change using the acme/autocert TLS config, and v1.6.1 lists server/server.go adopting the TLS config provided by acme/autocert. Termination details are exactly the kind of thing that interacts badly with address detection, so it belongs in the same conversation.

## Four storage backends behind one interface

The README states the supported providers plainly: s3, gdrive, storj, and the local file system. Nothing else is claimed, and the release history shows why the list changes slowly. A release titled Accept range requests, client side gpg encryption and file paste on front-end includes server: reorganize storage layer into more clear subfolder and all: update gdrive client and various linting cleanups, which is what moving an interface under several providers looks like in practice.

The go.mod lines up with the feature list. The AWS SDK v2 appears with its config, credentials and s3 service packages plus the s3 manager feature package for multipart uploads. Google support comes through google.golang.org/api and golang.org/x/oauth2, which is what gdrive needs for a user token. Storj is a first class dependency rather than an afterthought, with storj.io/uplink and storj.io/common pinned, so decentralised object storage is a real target rather than a claim in the README.

Around those sit the pieces that make the server a server: github.com/gorilla/mux for routing, github.com/gorilla/handlers for middleware, github.com/urfave/cli/v2 for the flag surface that matches the configuration table, github.com/VojtechVitek/ratelimit for throttling, github.com/dutchcoders/go-clamd for antivirus, github.com/Aetherinox/go-virustotal for the scanning endpoint, github.com/skip2/go-qrcode, github.com/tg123/go-htpasswd for htpasswd parsing, github.com/microcosm-cc/bluemonday with russross/blackfriday/v2 for HTML handling, and github.com/fatih/color for terminal output. The module targets Go 1.22.0 in go.mod while the container defaults to Go 1.24.

That combination is the practical answer to how you choose a backend: if you want encryption keys you control, look at the cloud object stores; if you want to avoid a single provider holding plaintext, Storj is the reason to read the dependency list carefully.

## Self hosting through flags or a scratch container

The configuration table already covers the flags. The Dockerfile covers the packaging, and it is a two stage build ending in FROM scratch AS final, which means the shipped image is the compiled binary plus a handful of copied files rather than a distribution with a shell.

The build stage does the usual Go work. It copies go.mod and go.sum first so the dependency download caches independently of the source, then copies the tree and builds:

```dockerfile
COPY go.mod go.sum ./

RUN go mod download

COPY . .

# build & install server
RUN CGO_ENABLED=0 go build -tags netgo -ldflags "-X github.com/dutchcoders/transfer.sh/cmd.Version=$(git describe --tags) -a -s -w -extldflags '-static'" -o /go/bin/transfersh
```

CGO_ENABLED=0 plus netgo and a static external link flag is what makes a no-libc final stage viable at all. The version string is stamped in through the cmd.Version symbol using git describe, so tags matter if you build from a clone.

The unprivileged user handling is the more unusual part. It builds passwd, shadow, group and groupshadow entries by hand when a RUNAS argument is set, with PUID and PGID defaulting to 5000, and the final stage copies those files into place before setting USER. A scratch image has no user database to add to, so this is the mechanism rather than a workaround. The entrypoint is the binary with an explicit listener:

```dockerfile
ENTRYPOINT ["/go/bin/transfersh", "--listener", ":8080"]
```

The container defaults to port 8080 rather than 80, which lines up with running behind a reverse proxy that terminates TLS, since force-https and the tls-listener flags only matter if you let the process own the certificate.

The tree also carries a Vagrantfile and a flake.nix alongside the Dockerfile, so there are two other routes to a local instance for people who want one.

## Why the project asks you to host your own copy

The README's disclaimer section is unusual enough to quote in spirit. A maintainer is also the person hosting a well known public installation, the two are described as unrelated, and no third party public installation is advertised or mentioned in the repo, the stated reason being security. The stated position of the maintainer is that if you want to use the software you should host your own installation.

The reasoning holds together. A public install of a service that accepts anonymous PUT requests, stores the bytes on infrastructure the operator chooses, hands back a URL with a short key in it, and supports a deletion header is a target, and advertising more installs makes the existing ones more useful to an attacker. The topics list, which still carries hacktoberfest and hacktoberfest2021 alongside docker, golang, share, transfer and transfersh, dates that intent to a specific period rather than describing the code.

The repository state supports the same picture. It is MIT licensed, not archived, 15898 stars with 1579 forks, 62 open issues, and the last push was 2026-06-13 on the main branch. Three releases are published, and the two most recent are the security fixes described above, which suggests a project that is maintained but releasing deliberately rather than frequently.

One practical takeaway for anyone reading the go.mod list: the client is curl, the server is one static binary, and every configuration knob is either a flag or an environment variable. There is no migration story to plan for, so the real decision is which storage provider to point at and whether anything is sitting in front of it to fix the header trust issue.

## Conclusion

transfer.sh is worth reading because the design is so narrow that nothing is hidden: an upload is an HTTP PUT, a download is a GET on a URL whose path encodes the file name and a short key, and every policy decision is a request or response header. The project asks you to run it yourself, which is both the security advice and the reason the s3, gdrive and storj backends exist, since the operator of a public install should be the one choosing where bytes land. The part to take seriously before exposing anything is the advisory sitting above the README title about IP filter and HTTP auth bypass through X-Forwarded-For spoofing, plus the two v1.6 releases that fixed basic auth being disabled. Trust tomasen/realip to report a genuine client address or you may be reading the wrong address, and put the service behind something that terminates TLS and authenticates before it takes traffic.

## FAQ

### How do I upload a file to transfer.sh?

Use curl with the file name in the URL path, as the README shows: curl --upload-file ./hello.txt https://transfer.sh/hello.txt. The server responds with the generated URL, and a response header carries the deletion URL you keep for yourself.

### Can I make a link that expires or can only be downloaded once?

Yes. Send the Max-Days or Max-Downloads request header with the upload, for example -H "Max-Days: 1" or -H "Max-Downloads: 1". The README documents both as the number of days before deletion and the limit on the number of downloads.

### How do I delete a file I uploaded?

The upload response includes an X-Url-Delete header containing the deletion URL. Sending a DELETE request to that URL removes the file, which is why the README's example greps the headers out of a single upload rather than making you guess the address.

### Is it safe to use a public transfer.sh instance for private files?

The project's own README advises hosting your own installation, and warns against using the server side X-Encrypt-Password feature on a third party instance since that service would hold the password. The README also opens with an advisory about IP filter and HTTP auth bypass through X-Forwarded-For spoofing, and the v1.6.0 and v1.6.1 releases were both auth bypass fixes.

## Sources

- [dutchcoders/transfer.sh on GitHub](https://github.com/dutchcoders/transfer.sh)
- [License: MIT](https://github.com/dutchcoders/transfer.sh/blob/main/LICENSE)
- [Project website](https://github.com/dutchcoders/transfer.sh)
- [README](https://github.com/dutchcoders/transfer.sh/blob/main/README.md)
- [Releases](https://github.com/dutchcoders/transfer.sh/releases)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/dutchcoders-transfer-sh
