Upload a file, get a hard-to-guess URL.
A drop box your coding agents can use: one Go binary, no database, your disk.
That is the whole idea. Downloads need no token; uploads and deletes do.
The same thing as text, for copying
curl -X POST \
-H "Authorization: Bearer $GODROP_TOKEN" \
-F "file=@photo.jpg" \
https://files.example.com/upload{
"files": [
{
"url": "https://files.example.com/f/20260815-143022-8f4e2c91b7934b38a72d1c0e5b6a4f3d/photo.jpg",
"name": "photo.jpg",
"size_bytes": 12345
}
]
}Coding agents write the code, run the tests and open the pull request. Asking them for a screenshot is the cheapest way to review the branches you are never going to check out and run yourself. They can take one: they start the app, drive a browser and capture the page. Then they cannot attach it.
GitHub has no supported API for attaching an image to a pull request. The web interface takes a drag and drop, and that is the whole of it (cli/cli#4228, #1895, #12960 "critical for agentic workflows", community#28219). What is left is committing binaries into the repository, an undocumented upload endpoint that can stop working on any deploy, or a bucket with a policy, a key and a bill attached to it.
An agent that can run curl can do this instead:
URL=$(curl -sS -X POST -H "Authorization: Bearer $GODROP_TOKEN" \
-F "file=@login-after-fix.png" $GODROP_URL/upload | jq -r '.files[0].url')
gh pr comment 42 --body "Login page after the fix:
"The identifier carries 128 random bits and the URL needs no token to open, so
GitHub renders it inline for everyone on the thread. The file itself is on your
own disk, which is where a picture of your unreleased work belongs, and it can
delete itself: -H "X-Expires-In: 30d".
Nothing about this is specific to screenshots. It is the same three lines for a video of a flaky test, a profile, a build log too long for a comment, or a generated report. What an agent needs to do it by itself, including a skill it can install, is in For AI agents.
curl -fsSL https://raw.githubusercontent.com/fatihbaltaci/GoDrop/v1.3.1/install.sh | shThe script picks the right binary for your machine, verifies its SHA-256
checksum against the published SHA256SUMS, installs it, and hands over to a
setup wizard that writes your configuration, creates your first token and
checks that the internet can actually reach you.
No root anywhere. The binary goes to ~/.local/bin, the configuration to
~/.godrop, and the uploads into a docker volume, so updating and removing
GoDrop later are the work of the same account. Run it as root, or set
GODROP_BIN_DIR=/usr/local/bin, to install it for everybody instead. The one
thing that does need root, once, is being in the docker group; setup checks
that before it writes anything.
Other ways:
# Docker: comes back up after a reboot, and after a crash
docker run -d --name godrop --restart always \
-p 8747:8747 \
-e GODROP_TOKENS=$(openssl rand -hex 16) \
-v godrop-data:/data \
ghcr.io/fatihbaltaci/godrop
# Debian or Ubuntu: installs the systemd service and creates its user.
# Download the .deb for your architecture from the releases page:
# https://github.com/fatihbaltaci/GoDrop/releases/latest
sudo dpkg -i godrop_1.3.1_linux_amd64.deb
# Fedora, RHEL or openSUSE
sudo rpm -i godrop_1.3.1_linux_amd64.rpm
# From source (Go 1.26+). Binaries built this way send no telemetry at all.
go install github.com/fatihbaltaci/GoDrop/cmd/godrop@latestEvery package, archive and checksum is on the releases page, and the container image is at ghcr.io/fatihbaltaci/godrop.
| Server | CLI | Notes | |
|---|---|---|---|
| Linux (amd64, arm64) | ✅ | ✅ | Where it is meant to run: .deb, .rpm, .apk, container image, systemd unit |
| macOS (Intel, Apple silicon) | ✅ | ✅ | Fine for development and small installs; no systemd, so use Docker or start it yourself |
| Windows (amd64, arm64) | ✅ | The binary works and is tested in CI, but there is no service wrapper, no installer script and no firewall guidance. Take the zip from Releases | |
| FreeBSD | 🔧 | 🔧 | Compiles and passes tests; no binaries published |
The suite runs on Linux, macOS and Windows for every change, and each release artefact carries signed build provenance:
gh attestation verify godrop_1.3.1_linux_amd64.tar.gz --repo fatihbaltaci/GoDrop$ godrop init
GoDrop 1.3.1 setup
Upload a file, get a hard-to-guess URL.
Public address
? Public URL https://files.example.com
Service
? How should it run? docker compose
HTTPS
? Certificate GoDrop gets one from Let's Encrypt
Limits
? Settings Recommended: 100MB per file, 20GB quota, no expiry
Checks
✓ storage docker volume godrop-data
✓ output directory /home/you/.godrop
✓ docker compose available
✓ port 443 is free
Written
✓ /home/you/.godrop/.env (chmod 600, contains your token)
✓ /home/you/.godrop/docker-compose.yml
✓ /home/you/.godrop/sample.png (a picture, so the first example below uploads something)
Your API token
┌─────────────────────────────────────┐
│ gd_7f3a9c2e1b8d4a6f0c5e2d9b3a7f1e4c │
└─────────────────────────────────────┘
⚠ shown once and never again, so copy it now
Starting
✓ containers started
Verifying
✓ firewall ufw allows port 443
✓ firewall ufw allows port 80
✓ external access reachable (HTTP 200, from FRA)
Use it
curl -X POST -H "Authorization: Bearer gd_7f3a…" \
-F "file=@/home/you/.godrop/sample.png" https://files.example.com/upload
...
Anonymous heartbeat
- once a day: {install_id, version, os, arch, deploy}
→ turn it off any time with: godrop telemetry offFour questions, and the last one is a choice between the recommended limits and setting them yourself. Everything the answers depend on is checked before a single file is written, so a setup that cannot work says so at the start rather than at the end, and the service is started and verified here rather than left as a command to paste. The picture is there so that the first command you paste uploads a real file.
Answering "GoDrop gets one from Let's Encrypt" is all HTTPS takes: it obtains the certificate itself and renews it, so there is no proxy to install and nothing to configure. The question only offers it for a name Let's Encrypt can actually issue for, and the listen port question disappears, because serving TLS means 443.
Changing your mind later is the same command with the setting on it. The token, the uploads and every answer you do not mention stay where they are, and the service restarts into the change:
godrop init --base-url https://files.example.com # or --port, --retention, --tls…Every question shows its default and can be answered with a flag instead, so CI
and agents run the same code path without a terminal. Prompts are skipped
automatically when there is no TTY; --no-input makes that explicit:
godrop init --no-input \
--base-url https://files.example.com \
--data-dir /var/lib/godrop \
--max-total-size 20GB --jsonThe wizard only offers what the host can do: systemd appears on Linux, not on macOS or Windows, and the commands it prints use the right shell.
Nine endpoints, no SDK, no client library: an agent that can call curl has
everything, and GET /llms.txt describes this exact instance in plain text.
| Method | Path | Auth | Purpose |
|---|---|---|---|
POST |
/upload |
✅ | multipart, one or more file fields |
PUT |
/upload/{name} |
✅ | raw request body |
GET/HEAD |
/f/{id}/{name} · /f/{id}.{ext} |
download; supports Range and ETag | |
DELETE |
/f/{id}/{name} · /f/{id}.{ext} |
✅ | delete |
GET |
/healthz |
liveness | |
GET |
/readyz |
readiness: is storage writable | |
GET |
/stats |
✅ | file count, bytes, quota, uptime |
GET |
/llms.txt · /openapi.yaml |
machine-readable description | |
GET |
/skill.md |
the agent skill, installable by URL |
# Several files at once: all succeed or none do, and the response lists them
# in the order they were sent
curl -X POST -H "Authorization: Bearer $GODROP_TOKEN" \
-F "file=@a.png" -F "file=@b.pdf" -F "file=@notes.txt" \
https://files.example.com/upload
# A file that deletes itself: 30m, 12h, 7d, 30d
curl -X POST -H "Authorization: Bearer $GODROP_TOKEN" \
-H "X-Expires-In: 7d" \
-F "file=@invoice.pdf" \
https://files.example.com/upload
# ?expires=7d does the same thing, for clients that cannot set a header
# Upload without multipart
curl -X PUT --data-binary @report.pdf \
-H "X-Expires-In: 24h" \
-H "Authorization: Bearer $GODROP_TOKEN" \
https://files.example.com/upload/report.pdf
# Download (no token), or force a download instead of inline rendering
curl -O https://files.example.com/f/20260815-143022-8f4e…/photo.jpg
curl -O "https://files.example.com/f/20260815-143022-8f4e….jpg?dl=1"
# Delete
curl -X DELETE -H "Authorization: Bearer $GODROP_TOKEN" \
https://files.example.com/f/20260815-143022-8f4e…/photo.jpgBoth Authorization: Bearer <token> and X-API-Key: <token> are accepted.
An upload answers with one entry per file, in the order they were sent, and
with Location pointing at the first:
{
"files": [
{ "url": "https://files.example.com/f/20260815-143022-8f4e…/a.png", "name": "a.png", "size_bytes": 8123 },
{ "url": "https://files.example.com/f/20260815-143024-b71d…/b.pdf", "name": "b.pdf", "size_bytes": 91234,
"expires_at": "2026-08-22T14:30:24Z" }
]
}One shape whatever was sent, so nothing has to branch on how many files there
were. The identifier, the extension and therefore the media type are all in the
URL, so the response does not repeat them; the name is there because the
cosmetic part of the URL is a slug and cannot give it back. expires_at
appears only when the upload asked for one, and GODROP_RETENTION is a
maximum, so a longer request is capped at it.
Without a JSON parser to hand, the header is the whole answer:
curl -sS -D- -o /dev/null -X POST -H "Authorization: Bearer $GODROP_TOKEN" \
-F "file=@photo.jpg" https://files.example.com/upload | grep -i '^location:'Status codes: 201 uploaded · 204 deleted · 400 malformed body or too
many files · 401 bad token · 404 unknown id, or the name's extension does
not match the stored file · 413 file too large · 415 not multipart · 429
rate limited (honour Retry-After) · 507 quota full.
GoDrop is built to be driven by coding agents rather than by a person at a browser. Give one a base URL and a token and it can discover the rest:
curl https://files.example.com/llms.txt # the whole API as plain text
curl https://files.example.com/openapi.yaml # machine-readable schema
curl https://files.example.com/skill.md # an agent skill it can keep- Every command accepts
--json, and in that mode prints nothing but the document, so parsing never breaks - Colour and interactive prompts switch themselves off when there is no terminal, so an agent can never get stuck on a form
- One request per file, no multipart client library, no SDK to keep up to date:
curlandjqare the whole toolchain
godrop token create --name claude-code --json | jq -r .token
godrop doctor --json | jq '.ok'GoDrop ships an agent skill: the instructions a coding agent needs to upload a file and hand back a link, without being told how. Install it with GoDrop itself:
godrop skill install --scope user # available in every project
godrop skill install # or just this repository
godrop skill install --agent claude # Claude Code's own directoryOr with the GitHub CLI, which knows where every agent keeps them:
gh skill install fatihbaltaci/GoDrop godrop --scope userEvery running instance serves the same skill at /skill.md, so an agent that
has been given nothing but a hostname can install it without the repository or
the binary:
npx skills add https://files.example.com/skill.mdThe skill holds no secrets. It reads GODROP_URL and GODROP_TOKEN from the
environment, so it is safe to commit alongside a project:
export GODROP_URL=https://files.example.com
export GODROP_TOKEN=$(godrop token create --name claude-code --json | jq -r .token)With those two values in its environment, an agent can finish a pull request without anyone opening a browser:
# after the agent has captured ./screenshots/checkout.png
URL=$(curl -sS -X POST -H "Authorization: Bearer $GODROP_TOKEN" \
-H "X-Expires-In: 30d" -F "file=@screenshots/checkout.png" \
$GODROP_URL/upload | jq -r '.files[0].url')
gh pr comment "$PR" --body "Checkout, after the fix:
"Not every agent has a shell. A desktop assistant, an IDE panel or a workflow
node can produce a chart, a report or a screenshot and then have nowhere to put
it, because it cannot run curl. For those, GoDrop serves an MCP endpoint at
/mcp, behind the same token as everything else:
claude mcp add --transport http godrop https://files.example.com/mcp \
--header "Authorization: Bearer $GODROP_TOKEN"Any client that takes a URL and a header works the same way, whether it is
configured through a command or through a mcpServers block of JSON.
| Tool | What it does |
|---|---|
upload_file |
Stores a file and returns the public URL, with an optional expires_in |
delete_file |
Removes a file, given the URL the upload returned |
storage_stats |
What is stored, and the limits this instance enforces |
The endpoint is built on protocol revision 2026-07-28, the one that took the
sessions out of MCP: there is no handshake and no session identifier, every
request carries its own version and capabilities, and the server keeps nothing
between them. That is the same way the rest of GoDrop works, which is why the
endpoint is one file and not a dependency.
Clients built on the older revisions still open with a handshake, and they are answered as well: the same three tools, and still no session assigned, so there is nothing to carry, resume or tear down in either era. Both paths are checked against the official MCP Inspector.
Files travel base64 encoded inside JSON, so upload_file takes at most 16MB.
An agent that can run a shell should keep using POST /upload: it streams,
it has no size limit beyond the configured one, and it is one request either
way.
godrop mcp is the same protocol over stdin and stdout, for a client that runs
a command instead of calling a URL:
{"mcpServers": {"godrop": {"command": "godrop", "args": ["mcp"]}}}That is the whole configuration. The address and the token come from the
installation on this machine, so the client is never told a secret, and every
message is passed through to /mcp unchanged, so the tools are the same ones.
It adds one that only works there. upload_local_file takes a path and
streams the file, which means no base64, no 16MB ceiling and nothing held in
memory: a 60MB build artefact goes up in a second and comes back as a URL.
godrop mcp --root ~/screenshots # refuse to read anything outside thisWorth setting when an agent runs unattended: without it the command can read any file the user can, which is the point of it and also the risk.
$ godrop token create --name claude-code
gd_7f3a9c2e1b8d4a6f0c5e2d9b3a7f1e4c # shown once, usable immediately
$ godrop token list
NAME CREATED LAST USED
claude-code 2 min ago just now
ci 12 days ago 3 hours ago
$ godrop token revoke ci # effective within a secondEvery one of these commands, and godrop telemetry, godrop health and
godrop uninstall with them, works on the installation this machine has, not
on the shell's idea of one: on a compose deployment they run where the files
are, in the service's own container.
Tokens are stored as SHA-256 digests in <data-dir>/tokens.json (mode
0600). A leaked file cannot be turned back into a working token, and a backup
restored onto another machine keeps working, which machine-bound encryption
would break for no security gain. A running server notices new and revoked
tokens without a restart.
Give every agent, script and machine its own token: revoking one leaves the
rest alone, and LAST USED is how you find out which of them is still wired
into something.
There are two places a token can come from, and both are accepted at once.
GODROP_TOKENS in the environment is the one the service starts with: it is
what the generated .env holds, because a compose deployment has no data
directory on the host to write a file into until the container has made the
volume, and it is the only thing that works on Fly and Railway. It has no name,
so it is not a row in godrop token list; the list says where it is instead.
tokens.json is what godrop token create adds to afterwards, and the only
kind that can be revoked without a restart.
On a compose installation that file is inside the container. The commands are the same ones: GoDrop runs them where the file is, using the same binary the service runs, rather than writing a token nothing would read.
Every command documents itself. This is that output, generated from the binary so it cannot drift:
godrop --help
$ godrop --help
GoDrop is a tiny self-hosted file host.
Start the server with "godrop serve"; the container image does that by itself.
Usage:
godrop [flags]
godrop [command]
Available Commands:
completion Generate the autocompletion script for the specified shell
doctor Diagnose the installation and print how to fix what is broken
health Probe a running instance (used by the container HEALTHCHECK)
help Help about any command
init Guided setup: configure, generate a token, start and verify
mcp Serve the Model Context Protocol on stdin and stdout
serve Start the HTTP server
skill Install the agent skill that teaches a coding agent to use GoDrop
telemetry Inspect or change the anonymous heartbeat
token Create, list and revoke API tokens
uninstall Remove GoDrop from this machine
update Update GoDrop to the latest release
version Print version information
Flags:
-h, --help help for godrop
--json machine-readable output
--no-color disable coloured output
-v, --version version for godrop
Use "godrop [command] --help" for more information about a command.godrop serve --help
$ godrop serve --help
Start the HTTP server.
Everything is configured through the environment, so there are no flags to
learn and the same settings work under systemd, Docker and a bare shell. See
.env.example for the annotated list, or run "godrop doctor" to see what the
current environment actually resolves to.
Plain http is fine on loopback, on a private network or over Tailscale. On a
public address, GODROP_TLS=auto gets a certificate from Let's Encrypt and
renews it, and GODROP_TLS_CERT with GODROP_TLS_KEY uses one you already have.
Neither needs a reverse proxy.
Usage:
godrop serve [flags]
Flags:
-h, --help help for serve
Global Flags:
--json machine-readable output
--no-color disable coloured outputgodrop init --help
$ godrop init --help
Walks through the handful of decisions GoDrop needs, writes the configuration
files, creates your first API token and, if you like, starts the service and
confirms that the outside world can actually reach it.
Every answer can be supplied as a flag instead; with --no-input the same wizard
runs without asking anything, which is what CI and agents should use. Prompts
are skipped automatically when there is no terminal.
Usage:
godrop init [flags]
Flags:
--base-url string public URL, e.g. https://files.example.com
--data-dir string where uploaded files are stored (default /var/lib/godrop as root, ~/.local/share/godrop otherwise)
--deployment string compose, systemd or env (default "compose")
--force overwrite existing configuration files
-h, --help help for init
--limits set the size, quota, retention and port questions yourself
--max-file-size string per-file limit, e.g. 100MB (default "100MB")
--max-total-size string storage quota, empty for unlimited (default "20GB")
--no-external-check do not ask godrop.sh to verify reachability
--no-input never prompt; use flags and defaults (for CI and agents)
--out-dir string where to write the generated files (default: working directory)
--port string listen port (default "8747")
--retention string delete files after this long, e.g. 30d
--start start the service when setup finishes (interactive setup does anyway; --start=false stops it)
--telemetry send the anonymous daily heartbeat (default true)
--tls string auto (Let's Encrypt), file, proxy or none
--tls-cert string certificate chain in PEM, with --tls=file
--tls-key string private key in PEM, with --tls=file
--token-name string name for the generated token (default "default")
Global Flags:
--json machine-readable output
--no-color disable coloured outputgodrop token --help
$ godrop token --help
Manage the API tokens that authorise uploads and deletes.
Tokens are stored as SHA-256 digests, so the clear-text value is shown exactly
once, when it is created. A running server notices changes within a
second, with no restart needed.
Usage:
godrop token [command]
Available Commands:
create Create a new API token
list List tokens (names only, values are not recoverable)
revoke Revoke a token by name
Flags:
--data-dir string data directory (default $GODROP_DATA_DIR or ./data)
-h, --help help for token
Global Flags:
--json machine-readable output
--no-color disable coloured output
Use "godrop token [command] --help" for more information about a command.godrop token create --help
$ godrop token create --help
Create a new API token
Usage:
godrop token create [flags]
Flags:
-h, --help help for create
--name string label for this token (e.g. claude-code, ci, blog)
Global Flags:
--data-dir string data directory (default $GODROP_DATA_DIR or ./data)
--json machine-readable output
--no-color disable coloured outputgodrop token list --help
$ godrop token list --help
List tokens (names only, values are not recoverable)
Usage:
godrop token list [flags]
Flags:
-h, --help help for list
Global Flags:
--data-dir string data directory (default $GODROP_DATA_DIR or ./data)
--json machine-readable output
--no-color disable coloured outputgodrop token revoke --help
$ godrop token revoke --help
Revoke a token by name
Usage:
godrop token revoke <name> [flags]
Flags:
-h, --help help for revoke
Global Flags:
--data-dir string data directory (default $GODROP_DATA_DIR or ./data)
--json machine-readable output
--no-color disable coloured outputgodrop doctor --help
$ godrop doctor --help
Checks configuration, storage, security posture, network reachability and
available updates, then prints the exact command that fixes each problem.
Run it on the server for the full picture, or point it at a remote instance
with --url. Exits non-zero when a check fails, so it works as a deployment
gate.
The token for a remote check is read from GODROP_TOKEN, so that it stays out
of the process list and the shell history:
GODROP_TOKEN=gd_... godrop doctor --url https://files.example.com
Usage:
godrop doctor [flags]
Flags:
--check-url string reachability service (default https://godrop.sh/api/check)
-h, --help help for doctor
--offline skip every check that needs the network
--token string API token for the round-trip check; prefer GODROP_TOKEN, which does not appear in the process list
--url string diagnose a remote instance at this base URL
Global Flags:
--json machine-readable output
--no-color disable coloured outputgodrop skill --help
$ godrop skill --help
Agent skills are folders holding a SKILL.md that tells a coding agent how
to do something. GoDrop ships one, so an agent that has never seen it can
upload a file and hand back a link without being told how.
The skill needs no secrets: it reads GODROP_URL and GODROP_TOKEN from the
environment, so the same file is safe to commit alongside a project.
It can also be installed with the GitHub CLI, which supports every agent:
gh skill install fatihbaltaci/GoDrop godrop --scope user
Usage:
godrop skill [command]
Available Commands:
install Write the skill into an agent's skill directory
show Print the skill, so it can be piped somewhere else
Flags:
-h, --help help for skill
Global Flags:
--json machine-readable output
--no-color disable coloured output
Use "godrop skill [command] --help" for more information about a command.godrop skill install --help
$ godrop skill install --help
Write the skill into an agent's skill directory.
Project scope (the default) installs into the working directory, so the skill
travels with the repository. User scope installs into your home directory,
where every project can see it.
Usage:
godrop skill install [flags]
Flags:
--agent string shared (most agents) or claude; use --dir for anything else (default "shared")
--dir string install into this directory instead, overriding --agent and --scope
--force replace an existing skill
-h, --help help for install
--scope string project or user (default "project")
Global Flags:
--json machine-readable output
--no-color disable coloured outputgodrop skill show --help
$ godrop skill show --help
Print the skill, so it can be piped somewhere else
Usage:
godrop skill show [flags]
Flags:
-h, --help help for show
Global Flags:
--json machine-readable output
--no-color disable coloured outputgodrop update --help
$ godrop update --help
Download the newest release and put it in place of this binary.
Nothing is replaced until the download has been checked against the published
SHA256SUMS and the new binary has been run and seen to report its own version,
so a failed update leaves the working installation exactly as it was. The file
is then swapped with a rename, which is atomic: a server that is already
running keeps serving from the binary it started with, and restarts into the
new one.
Installations owned by something else are refused rather than overwritten. Use
apt, dnf or brew for those.
When setup configured a service on this machine, that service is moved onto the
new release too: a compose deployment is pulled and recreated, a systemd one is
restarted. The configuration, the token and the uploads are untouched.
Usage:
godrop update [flags]
Flags:
--check report whether a newer release exists, without installing it
-h, --help help for update
--version string install this release instead of the newest, e.g. v1.2.0
Global Flags:
--json machine-readable output
--no-color disable coloured outputgodrop telemetry --help
$ godrop telemetry --help
GoDrop sends one anonymous heartbeat per day:
{install_id, version, os, arch, deploy}
That is the whole payload. No file names, no identifiers, no counters, no
addresses, no base URL. Use `godrop telemetry status --json` to see the exact
body that would be transmitted.
Usage:
godrop telemetry [command]
Available Commands:
off Disable the anonymous heartbeat
on Enable the anonymous heartbeat
status Show whether telemetry is active and what would be sent
Flags:
--data-dir string data directory (default $GODROP_DATA_DIR or ./data)
-h, --help help for telemetry
Global Flags:
--json machine-readable output
--no-color disable coloured output
Use "godrop telemetry [command] --help" for more information about a command.godrop health --help
$ godrop health --help
Probe a running instance (used by the container HEALTHCHECK)
Usage:
godrop health [flags]
Flags:
-h, --help help for health
--url string URL to probe (default: the local listen address)
Global Flags:
--json machine-readable output
--no-color disable coloured outputgodrop version --help
$ godrop version --help
Print version information
Usage:
godrop version [flags]
Flags:
-h, --help help for version
Global Flags:
--json machine-readable output
--no-color disable coloured outputEverything is an environment variable. Sizes accept 100MB, 2GB, 512KB;
durations accept 30d, 12h, 90m; rates accept 60/m, 10/s, 100/h.
| Variable | Default | Meaning |
|---|---|---|
GODROP_TOKENS |
(required) | Comma-separated API tokens |
GODROP_BASE_URL |
(from request) | Public URL used in responses |
GODROP_ADDR |
:8747 (:443 with TLS) |
Listen address |
GODROP_DATA_DIR |
./data |
Where files live (/data in the image) |
GODROP_TLS |
off |
auto for Let's Encrypt, file for your own certificate |
GODROP_TLS_DOMAINS |
(from base URL) | Names to get a certificate for |
GODROP_TLS_EMAIL |
(none) | Expiry warnings from Let's Encrypt |
GODROP_TLS_CACHE_DIR |
<data dir>/acme |
Account key and certificates |
GODROP_TLS_CERT / GODROP_TLS_KEY |
(none) | Full chain and key, in PEM |
GODROP_HTTP_ADDR |
:80 with TLS |
Redirect and challenge listener, off to disable |
GODROP_MAX_FILE_SIZE |
100MB |
Per-file limit → 413 |
GODROP_MAX_FILES_PER_REQUEST |
20 |
Files per multipart request |
GODROP_MAX_TOTAL_SIZE |
(unlimited) | Storage quota → 507 |
GODROP_RETENTION |
(forever) | Delete uploads older than this |
GODROP_CACHE_MAX_AGE |
8760h |
How long caches may hold a download; also how long a deleted one can outlive the delete. 0 forbids caching |
GODROP_RATE_LIMIT |
(off) | Uploads per token |
GODROP_AUTH_RATE_LIMIT |
(off) | Failed authentications per client address |
GODROP_CORS_ORIGINS |
* |
Browser origins allowed to call the API |
GODROP_READ_HEADER_TIMEOUT |
10s |
Slow-header protection |
GODROP_READ_TIMEOUT / GODROP_WRITE_TIMEOUT |
0 |
Body timeouts, off on purpose |
GODROP_IDLE_TIMEOUT |
120s |
Keep-alive idle timeout |
GODROP_SHUTDOWN_TIMEOUT |
30s |
Grace period for in-flight transfers |
GODROP_LOG_FORMAT / GODROP_LOG_LEVEL |
json / info |
Logging |
GODROP_ACCESS_LOG |
true |
Per-request log lines |
GODROP_TELEMETRY |
on |
Anonymous daily heartbeat |
The body timeouts default to
0deliberately. A 100MB file over a slow connection is legitimate and takes minutes; aWriteTimeoutwould cut it off mid-transfer. The size limit, not the clock, is what bounds an upload.
See .env.example for the annotated version.
data/2026/08/15/20260815-143022-8f4e2c91b7934b38a72d1c0e5b6a4f3d.jpg
└── date ──┘└── identifier: timestamp + 128 random bits ──┘└ext┘
The identifier is the index. It carries its own location, so a lookup needs no database and no directory scan; the timestamp keeps directories small and makes retention a directory-level operation; the 128 random bits make URLs unguessable. The extension is the only metadata kept. The MIME type is derived from it at download time.
There is no listing endpoint, by design. Keep the URL an upload returns.
Retention only ever deletes uploads. tokens.json and the telemetry markers
share the directory, and sweeping those away by age would revoke every token.
$ godrop doctor
Configuration
✓ tokens 2 token(s) configured
✓ base_url https://files.example.com
⚠ storage quota no quota set; uploads can fill the disk
→ set GODROP_MAX_TOTAL_SIZE=20GB
Storage
✓ writable /var/lib/godrop is writable
✓ usage 142 file(s), 834.2MB of 20.0GB (4%)
✓ disk space 38.2GB free of 79.0GB
✗ persistence /data is inside the container, not a volume
→ mount a volume: docker run -v godrop-data:/data …
Network
✓ dns files.example.com → 5.9.1.2
✓ tls valid, 67 days left (E5)
✗ external not reachable from the internet: connection refused
→ open the port in your provider's firewall (security group)
End to end
✗ proxy body limit a proxy rejected a tiny upload with 413
→ nginx: client_max_body_size 100m;Run it from any shell on the server: it reads the .env setup wrote, so the
diagnosis is of your installation rather than of an empty environment. A
compose deployment keeps its files in a volume only the container can see, so
the container is asked about those and this machine answers for the network;
one report comes out of the two.
It exits non-zero when a check fails, so it works as a deployment gate:
godrop doctor --json | jq '.checks[] | select(.status=="fail")'.
The reachability check asks https://godrop.sh/api/check to fetch your
/healthz from the public internet, the only way to catch a cloud firewall,
which is invisible from inside the machine. Only the URL is sent; skip it with
--offline.
To diagnose an instance from your own machine, pass its address and a token. The token goes in the environment, not on the command line, where the process list and the shell history would both keep a copy:
GODROP_TOKEN=gd_... godrop doctor --url https://files.example.comgodrop update --check # is there a newer release?
godrop update # install it, and move the service onto itNothing is replaced until the download has been checked against the published
SHA256SUMS and the new binary has been run and seen to report its own
version, so a failed update leaves the working installation exactly as it was.
The swap itself is a rename, which is atomic: a running server keeps serving
from the binary it started with and picks up the new one when it restarts.
The service is moved on too, because the binary you update is the command line
and the service may be a container running a different copy. A compose
deployment is pulled and recreated, which replaces the container and keeps the
named volume, so the uploads and the token survive it; a systemd deployment is
restarted. Re-running the installer, or godrop init, over an installation
that already exists does exactly the same thing rather than asking the
questions again:
curl -fsSL https://raw.githubusercontent.com/fatihbaltaci/GoDrop/v1.3.1/install.sh | sh # again: this is an updateAn installation that belongs to a package manager is refused rather than overwritten, with the command that does the job instead:
| Installed with | Update with |
|---|---|
install.sh, or a downloaded archive |
godrop update |
.deb |
sudo apt update && sudo apt install --only-upgrade godrop |
.rpm |
sudo dnf upgrade godrop |
| Docker | docker pull ghcr.io/fatihbaltaci/godrop |
go install |
go install github.com/fatihbaltaci/GoDrop/cmd/godrop@latest |
TLS is not required. GoDrop speaks plain http, and on a network where nobody can read the traffic that is the right choice rather than a compromise:
GODROP_TOKENS=$(openssl rand -hex 16) \
GODROP_BASE_URL=http://localhost:8747 \
godrop serveSet GODROP_BASE_URL to whatever the client will actually type, because that
is what the returned URLs are built from: http://localhost:8747,
http://100.101.102.103:8747 for a Tailscale address, or
http://nas.local:8747 on a home network. Leave it unset and the URL is
derived from the request, which also works.
godrop doctor judges plain http by who could be listening. Loopback and
Tailscale pass, because nothing readable leaves the machine in the first case
and the connection is already encrypted in the second. A LAN address warns:
tokens are readable by anything else on that network. A public address fails.
curl -fsSL https://raw.githubusercontent.com/fatihbaltaci/GoDrop/v1.3.1/install.sh | sh # installs and runs `godrop init`The wizard asks how you want the certificate, and "GoDrop gets one for me" is the first answer. See HTTPS below for what each answer does.
deploy/ holds a Caddy and an nginx
configuration for anyone who wants a proxy anyway, and a hardened
godrop.service for systemd. Whichever proxy you use,
raise its body size limit to match GODROP_MAX_FILE_SIZE. godrop doctor
tests this for you.
The .deb and .rpm packages do the systemd part for you: they install the
unit, create an unprivileged godrop user, and put the configuration in
/etc/godrop/godrop.env (kept across upgrades) with uploads in
/var/lib/godrop.
fly launch --no-deploy --copy-config
fly volumes create godrop_data --size 10
fly secrets set GODROP_TOKENS=$(openssl rand -hex 16)
fly deployCreate a service from this repository, add a volume mounted at /data, and
set GODROP_TOKENS and GODROP_BASE_URL. Without the volume, uploads vanish
on the next deploy.
Use render.yaml as a blueprint. The persistent disk requires a
paid instance and pins the service to one instance, because GoDrop stores files
on local disk and does not scale horizontally, by design.
GoDrop can serve https itself, so a public install needs no proxy at all:
GODROP_TLS=auto
GODROP_BASE_URL=https://files.example.comThat is the whole configuration. On the first request GoDrop gets a
certificate from Let's Encrypt, keeps it in <data dir>/acme and renews it
long before it expires.
Open 443 and 80 to the internet. Port 80 answers the certificate
challenge and redirects anyone who typed http://, so an install that opens
only 443 waits for a certificate that never arrives. On a VPS that means both
the host firewall and your provider's, which is invisible from inside the
machine:
sudo ufw allow 443,80/tcp
# and the same two ports in the AWS security group, Hetzner firewall or GCP rulegodrop init and godrop doctor both check the two ports and say which one
is missing. If port 80 is genuinely unavailable, set GODROP_HTTP_ADDR=off
and the certificate is still issued over 443 alone, through acme-tls/1.
Already have a certificate, from certbot, your company CA or your cloud provider? Name the two files and nothing else changes:
GODROP_TLS_CERT=/etc/letsencrypt/live/files.example.com/fullchain.pem
GODROP_TLS_KEY=/etc/letsencrypt/live/files.example.com/privkey.pemgodrop doctor then reports how many days that certificate has left, and
whether anyone else on the machine can read the key.
In Docker it is the same two variables and the two ports:
docker run -d --name godrop --restart always \
-p 443:443 -p 80:80 \
-e GODROP_TOKENS=$(openssl rand -hex 16) \
-e GODROP_TLS=auto \
-e GODROP_BASE_URL=https://files.example.com \
-v godrop-data:/data \
ghcr.io/fatihbaltaci/godrop| Your situation | Setting |
|---|---|
| Public domain, nothing in front | GODROP_TLS=auto |
| A certificate you already have | GODROP_TLS_CERT and GODROP_TLS_KEY |
| Caddy, nginx, Traefik or a cloud load balancer in front | leave TLS off |
| Loopback, a LAN, Tailscale, a private network | leave TLS off |
Turning TLS on moves the listener to :443 and starts a second one on :80,
unless you set GODROP_ADDR or GODROP_HTTP_ADDR yourself. Under systemd
that needs AmbientCapabilities=CAP_NET_BIND_SERVICE, which the shipped unit
already has; in Docker, publish -p 443:443 -p 80:80.
A certificate can only be issued for a public name that resolves to this
machine. nas.local, 10.0.0.5 and a Tailscale name are all refused at
startup, with the reason, rather than failing in a retry loop afterwards.
- Unguessable identifiers: 128 bits from
crypto/rand; no enumeration endpoint exists - No path traversal, structurally: the storage path is derived from the validated identifier alone; a client-supplied name never takes part in it
- Active content is never rendered:
.html,.svg,.xmland friends are always sent as downloads, withnosniffandContent-Security-Policy: default-src 'none'; sandboxon every response - Names cannot lie:
/f/<id>/setup.exedoes not resolve to a stored.jpg - Constant-time token comparison, digests at rest, tokens never logged
- Fails closed: the server refuses to start without a token
- Bounded everything: per-file size, per-request size, file count, storage quota, and optional per-token and per-address rate limits
- Logs are not a key ring: the random half of an identifier is cut short before it is written to a log, so a log reader cannot rebuild a download URL
- A delete is not an unpublish: the server stops answering for the URL at
once, and an expiring upload tells caches to hold it no longer than that, but
a URL already shared may still be served by a cache nobody here controls, and
GitHub's image proxy keeps its own copy of anything rendered in a thread.
Check a screenshot before uploading it.
GODROP_CACHE_MAX_AGEis the knob for an operator who would rather a delete took effect everywhere quickly
SECURITY.md sets out what GoDrop defends, what it deliberately does not, and a hardening checklist. Found something? Open a security advisory on GitHub rather than an issue.
GoDrop sends one anonymous heartbeat per day:
{"event":"heartbeat","distinct_id":"a3f19c…",
"properties":{"version":"1.3.1","os":"linux","arch":"arm64","deploy":"docker"}}That is the entire payload: no file names, no counts, no addresses, no base URL. It exists to answer "how many installations are there, on what, and how many are stuck on an old version". Setup does not ask about it: it says what is sent, at the end, along with the command that stops it.
godrop telemetry status --json # shows the exact body that would be sent
godrop telemetry off # or GODROP_TELEMETRY=offBinaries built from source have no telemetry key compiled in and never report.
make test # everything, with the race detector
make cover # coverage, failing below 100%
make fuzz # fuzz the input sanitisers
make run # a local server on port 48080
make docker # build the image
make snapshot # build every release artefact locally, without publishing
make docs # regenerate the command line reference aboveThe website lives in site/ and worker/, and is deployed
separately from the binary. See worker/README.md.
Releases come from GoReleaser via
.goreleaser.yaml: binaries, archives, checksums, Linux
packages and the changelog. CI builds the whole set on every change, so a tag
never fails on something that could have been caught earlier.
Every statement in internal/ is covered by a test, and CI fails if that ever
slips. cmd/godrop is a three-line shim around os.Exit and is excluded.
The fuzz corpus in internal/server/testdata/ includes a case that fuzzing
found: a file name that produced .. inside a URL segment.
Sponsored by
The gopher is after the Go mascot by Renée French, CC BY 3.0.