Filedrop — Public Drop URL
A single-purpose web uploader. Drag-and-drop in the browser, files land in /var/sftp/shared/uploads on disk — the same bucket SFTP users write into (see SFTP). No accounts, no UI to log into. Resumable uploads via the tus protocol.
Runs at /opt/filedrop/ and is the only service exposed via Tailscale Funnel instead of Cloudflare.
Public URL
https://discourse.<TAILNET>.ts.net/drop/<DROP_TOKEN>/
The <DROP_TOKEN> is a 32-char hex string baked into nginx.conf — anything outside that path returns 404. It's pure obscurity (not authentication), but combined with Tailscale Funnel's TLS-only ingress and the random URL, it's enough for a "share this link with one person" use case. Rotate it by editing nginx.conf and re-issuing a new URL to the recipient.
Architecture
Browser
│
│ HTTPS (TLS terminated by Tailscale Funnel)
▼
discourse.<TAILNET>.ts.net ──Funnel──> 127.0.0.1:8081
│
▼
┌─────────────────────────────────┐
│ nginx (filedrop-nginx-1) │
│ /drop/<TOKEN>/ → static │ ─┐
│ /drop/<TOKEN>/upload/ → tusd │ ─┼─> tusd container
│ / → 404 │ │
└─────────────────────────────────┘ │
▼
./tusdata/ (in-flight)
│
│ post-finish hook
▼
/var/sftp/shared/uploads/
▲
│
SFTP users write here too
/opt/filedrop/docker-compose.yml
services:
tusd:
image: tusproject/tusd
command: -upload-dir /data -hooks-dir /hooks -behind-proxy
volumes:
- ./tusdata:/data
- ./hooks:/hooks
- /var/sftp/shared/uploads:/uploads
restart: unless-stopped
nginx:
image: nginx:alpine
ports:
- "8081:80"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./nginx/html:/usr/share/nginx/html:ro
depends_on:
- tusd
restart: unless-stopped
/opt/filedrop/nginx/nginx.conf
events {}
http {
include mime.types;
default_type application/octet-stream;
server {
listen 80;
location /drop/<DROP_TOKEN>/ {
alias /usr/share/nginx/html/;
index index.html;
try_files $uri $uri/ =404;
}
location /drop/<DROP_TOKEN>/upload/ {
proxy_pass http://tusd:8080/files/;
proxy_http_version 1.1;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_request_buffering off;
client_max_body_size 0;
proxy_redirect ~^http://[^/]+/files/(.*)$ https://$host/drop/<DROP_TOKEN>/upload/$1;
}
location / {
return 404;
}
}
}
Notes:
proxy_request_buffering off+client_max_body_size 0— required for tus's PATCH-based resumable uploads. Without this, nginx would buffer the whole file and break resumability + cap at the default 1 MB.proxy_redirectrewrites tusd's internalLocation:headers (which point tohttp://.../files/...) back into the public/drop/<TOKEN>/upload/...path — without it, the browser would follow the redirect to a non-existent URL.- The container's
tusdhostname resolves via compose's default network.
/opt/filedrop/hooks/post-finish
Runs every time tusd finishes accepting an upload. Moves the file out of tusd's working dir into the shared bucket, with a collision-rename:
#!/bin/sh
input=$(cat)
src=$(echo "$input" | jq -r '.Event.Upload.Storage.Path')
filename=$(echo "$input" | jq -r '.Event.Upload.MetaData.filename // empty')
if [ -z "$filename" ]; then
filename=$(basename "$src")
fi
dest="/uploads/${filename}"
if [ -f "$dest" ]; then
dest="/uploads/$(date +%s)_${filename}"
fi
mv "$src" "$dest"
rm -f "${src}.info"
Behaviour:
- tusd passes the upload event as JSON on stdin.
- Filename comes from the client's
metadata.filename(sent by the JS uploader); falls back to tusd's internal id if absent. - If the destination already exists, it's prefixed with the unix timestamp — both versions are kept, no overwrites.
${src}.infois tusd's metadata sidecar; removed after a successful move.
The container needs jq (the tusproject/tusd image includes it). If the hook ever fails, the file stays in ./tusdata/ and tusd will not retry — manual cleanup needed.
Volumes
| Host path | Purpose |
|---|---|
./tusdata/ |
tusd's working dir (in-flight + partial files) |
./hooks/post-finish |
the script above |
/var/sftp/shared/uploads |
shared bucket — also mounted by SFTP users via the chroot |
./nginx/nginx.conf |
reverse proxy + obscurity gate |
./nginx/html/index.html |
the drag-drop UI (HTML + JS speaking tus) |
Rotating the drop URL
NEW_TOKEN=$(openssl rand -hex 16)
sudo sed -i "s/aa584cd70be711be6f0046304ecd3d12/$NEW_TOKEN/g" /opt/filedrop/nginx/nginx.conf
cd /opt/filedrop && docker compose restart nginx
echo "New URL: https://discourse.<TAILNET>.ts.net/drop/$NEW_TOKEN/"
Old links 404 immediately. Any in-flight tus upload via the old URL will fail at the next PATCH.
Customising the UI
Edit /opt/filedrop/nginx/html/index.html in place — :ro mount means nginx rereads on every request, no restart needed.
Operating
cd /opt/filedrop
docker compose up -d
docker compose logs -f tusd
docker compose restart nginx # after nginx.conf edits
# Inspect what's in the bucket
ls -la /var/sftp/shared/uploads/
# Inspect stuck in-flight uploads
ls /opt/filedrop/tusdata/
Why Funnel and not Cloudflare?
Funnel gives a public TLS URL with no DNS to manage — perfect for a single-purpose share. If the recipient changes or the use case ends, kill the funnel and the URL is gone. See Tailscale.
Navigation
Quick Links
Projects
Server (discourse)
- Host
- Cloudflare Tunnel
- Tailscale
- SFTP
- Discourse
- Forgejo
- Filedrop
- Filebrowser
- Landing Sites
- Onboarding
- Backups
- Recovery
Window Managers
Homelab
Docker
Software
Thoughts
Archived
Connect
Butterbian — my Debian 13 distro · butterrepo — community APT repo · butterknife — TUI installer
justaguylinux.com · Butterforge · Butter Lab · The Churn · YouTube · Mastodon · Links · Contact · Support on Buy Me a Coffee
"Not an expert. Just A Guy." — Licensed CC BY-SA 4.0 · © 2026 JustAGuy Linux