Self-hosted file send/receive. One container, one volume.
Short, shareable URLs to send files to people — or receive files from them.
Per-link policy, presigned uploads, optional Cloudflare Tunnel. No bucket required.
Quick start · Local mode · S3 mode · Cloudflare Tunnel · Config
- 📤 Receive links (
/r/<code>) — others upload to you. - 🗜️ Bulk receive downloads — download every file on a receive link as one ZIP.
- 📦 Send links (
/s/<code>) — bundle files into a download link. - 🔒 Per-link policy — label, password, max-uploads quota, expiry.
- ⚡ Presigned uploads — bytes go browser ↔ storage; server handles policy, not bytes.
- 🗂️ Local or S3 — bytes on disk, or any S3-compatible bucket (R2, MinIO, AWS, B2).
- 🌐 Optional Cloudflare Tunnel — public hostname without opening ports.
- 🧑💼 Single admin — no public signup, no teams.
git clone https://github.com/thatjuan/fileharbor.git
cd fileharbor
docker build -t fileharbor .Generate stable secrets once (regenerating invalidates sessions + in-flight presigned URLs):
mkdir -p /srv/fileharbor
openssl rand -hex 32 > /srv/fileharbor/auth-secret
openssl rand -hex 32 > /srv/fileharbor/storage-secretBytes live on the mounted /data volume alongside the SQLite database.
docker run -d \
--name fileharbor \
-p 127.0.0.1:3000:3000 \
-v fileharbor-data:/data \
-e BETTER_AUTH_SECRET="$(cat /srv/fileharbor/auth-secret)" \
-e BETTER_AUTH_URL="https://files.example.com" \
-e SECURITY_CLIENT_IP_SOURCE=x-real-ip \
-e STORAGE_SIGNING_SECRET="$(cat /srv/fileharbor/storage-secret)" \
fileharborSTORAGE_BACKEND defaults to local. Reverse-proxy https://files.example.com to 127.0.0.1:3000 on the host. Don't publish port 3000 publicly: a client that reaches it directly bypasses the proxy.
SECURITY_CLIENT_IP_SOURCE=x-real-ip makes rate limiting key off the address your proxy writes into X-Real-IP, and lets admin and auth actions pass the origin check when the public host differs from BETTER_AUTH_URL. The proxy must overwrite the header on every request. In nginx:
proxy_set_header X-Real-IP $remote_addr;For a proxy that only appends to X-Forwarded-For, use SECURITY_CLIENT_IP_SOURCE=xff instead (see .env.example).
Any S3-compatible bucket. Bucket must exist; CORS must allow PUT + GET from your BETTER_AUTH_URL origin and expose ETag. A HeadBucket probe runs at boot.
docker run -d \
--name fileharbor \
-p 3000:3000 \
-v fileharbor-data:/data \
-e BETTER_AUTH_SECRET="$(cat /srv/fileharbor/auth-secret)" \
-e BETTER_AUTH_URL="https://files.example.com" \
-e STORAGE_BACKEND=s3 \
-e S3_ENDPOINT="https://<account>.r2.cloudflarestorage.com" \
-e S3_ACCESS_KEY_ID="..." \
-e S3_SECRET_ACCESS_KEY="..." \
-e S3_BUCKET="fileharbor" \
-e S3_FORCE_PATH_STYLE=true \
fileharborSTORAGE_SIGNING_SECRET is not used in S3 mode (S3 signs its own URLs). The /data volume still holds SQLite.
The image bundles cftunn, which provisions a named Cloudflare Tunnel and the matching DNS record at startup. No port publish needed — Cloudflare routes traffic to the container.
Cloudflare API token scopes: Zone:DNS:Edit + Account:Cloudflare Tunnel:Edit.
Full local-storage + tunnel example:
docker run -d \
--name fileharbor \
--restart unless-stopped \
-v fileharbor-data:/data \
-e BETTER_AUTH_SECRET="$(cat /srv/fileharbor/auth-secret)" \
-e STORAGE_SIGNING_SECRET="$(cat /srv/fileharbor/storage-secret)" \
-e CLOUDFLARE_API_TOKEN="..." \
-e CLOUDFLARE_TUNNEL_DOMAIN="files.example.com" \
fileharborBETTER_AUTH_URL auto-derives to https://${CLOUDFLARE_TUNNEL_DOMAIN}. Drop -p 3000:3000 — the tunnel reaches the server inside the container.
No proxy setting is needed: in tunnel mode the server trusts CF-Connecting-IP, which Cloudflare always overwrites, and listens on 127.0.0.1 only, so requests can't skip Cloudflare.
For S3 + tunnel, combine: drop -p 3000:3000 from the S3 command above and add the two CLOUDFLARE_* vars (you can also drop BETTER_AUTH_URL).
Multipart uploads (files >100 MiB) automatically bypass the Cloudflare Free-plan 100 MB body cap.
JSON API request bodies are capped inside the app (64 KiB on public routes, 1 MiB for multipart complete, 16 KiB for setup and sign-in), so oversized requests get a 413. File bytes are not: they are capped only by STORAGE_MAX_OBJECT_SIZE_BYTES and any limit your proxy enforces.
/data is the in-container path. Replace the named volume with any host path:
-v /mnt/disks/fileharbor:/dataSQLite and (in local mode) object bytes both live under that mount. To split them, set LOCAL_OBJECTS_DIR to a second path and mount that too:
-v /mnt/disks/fileharbor:/data \
-v /mnt/objects/fileharbor:/objects \
-e LOCAL_OBJECTS_DIR=/objectsTo change the in-container path itself, set DATA_DIR (and mount the new path).
Two options, pick one:
- Web setup — open
https://<your-host>/setupand submit username + password. Route seals after first user. - Headless — set
ADMIN_USERNAME+ADMIN_PASSWORDin the env (both required, ignored after the admin exists).
Every value is env-driven. Authoritative list: apps/server/src/config.ts. Plain template: .env.example.clean. Commented template: .env.example.
| Variable | Purpose |
|---|---|
BETTER_AUTH_SECRET |
Signs session cookies. Required in production. |
BETTER_AUTH_URL |
Public base URL. Auto-derived in tunnel mode. |
STORAGE_BACKEND |
local (default) or s3. |
STORAGE_SIGNING_SECRET |
HMAC for local presigned URLs. Required in production (local). |
DATA_DIR |
SQLite + (local mode) bytes. Default /data in the image. |
LOCAL_OBJECTS_DIR |
Split local object bytes onto a separate path. |
S3_* |
Endpoint, keys, bucket. Required when STORAGE_BACKEND=s3. |
ADMIN_USERNAME / ADMIN_PASSWORD |
Headless admin seed. Both or neither. |
CLOUDFLARE_API_TOKEN / CLOUDFLARE_TUNNEL_DOMAIN |
Cloudflare Tunnel mode. Both or neither. |
STORAGE_MULTIPART_THRESHOLD_BYTES |
Multipart cut-over. Default 100 MiB. |
RATE_LIMIT_* |
In-memory abuse limits for auth/setup/public surfaces. |
SECURITY_HEADERS_* |
Security headers (on by default, false opts out) and HSTS controls. |
SECURITY_CLIENT_IP_SOURCE |
Trusted client-IP source: socket (default), cf-connecting-ip (tunnel default), x-real-ip, xff. |
BIND_HOST |
Listen address. Default 127.0.0.1 in tunnel mode, 0.0.0.0 otherwise. |
Pull the image, restart, same volume. Drizzle migrations run on every start.
The admin receive-link page downloads two or more received files as one streamed ZIP. Files pass through the File Harbor server one at a time, including in S3 mode, so the browser starts one normal download and needs no automatic-download or filesystem permission. Existing single-file downloads continue directly from storage.
Generated ZIPs are not cached or resumable. A retry creates a new snapshot, and an object deleted or changed during streaming causes the download to end as an incomplete archive. Size server bandwidth, reverse-proxy timeouts, browser disk space, and extraction-tool ZIP64 support for the largest expected download. S3 bulk downloads do not require additional browser CORS rules. The archive uses Archiver's ZIP stream with forced ZIP64 end records and zip-stream's data descriptors, which support streamed individual entries whose final size exceeds 4 GiB without pre-buffering them.
npm install
npm run devHono API on :3000, Vite on :5173 (proxies /api/* to Hono). Default local backend, bytes under ./data/objects.