Self-hosted temporary file sharing: uploads with expiry and view limits, pastes (optionally end-to-end encrypted), a URL shortener, and chibisafe/ShareX-compatible upload APIs. One Go binary, no database.
The web UI and paste viewer default to a light theme; the button in the header cycles light, dark and system (follows the OS setting), remembered per browser.
Fill out the .env.example and remove the .example
Dockerfile and compose are included. The container runs as UID 10001 and reads .env from a read-only mount, so it is never baked into the image. Both bind mounts must be accessible to that UID:
mkdir -p uploads && sudo chown -R 10001:10001 uploads
chmod 644 .env # or: sudo chown 10001 .env && chmod 600 .envUpgrading from the old root-based image needs the chown above, since existing uploads are owned by root.
If you are hosting this behind cloudflare, keep in mind they have a 100MB cap for proxied files.
go build -o teacup ./src # or: go run ./src
go test ./...All code lives in src/. Run the binary from the directory holding .env. The web UI (src/index.html) is embedded in the binary, so edits to it need a rebuild.
teacup does not rate-limit itself. Every failed login, Basic Auth or API key attempt is logged as:
2026/09/26 01:40:00 auth failure: remote=203.0.113.7 forwarded="" method=login
remote is the TCP peer. Behind a reverse proxy it is the proxy, so ban on the proxy's own logs or on forwarded (the X-Forwarded-For header, trustworthy only when your proxy overwrites it). A fail2ban filter for direct exposure:
[Definition]
failregex = auth failure: remote=<HOST> forwarded=| Key | Default | Meaning |
|---|---|---|
USERNAME, PASSWORD |
required | Login and HTTP Basic Auth credentials |
API_KEY |
unset (disabled) | Key accepted in the x-api-key header on every authenticated endpoint |
PORT |
8080 |
Listen port |
MAX_FILE_SIZE_MB |
100 |
Maximum size of one file |
MAX_FILES_PER_REQUEST |
20 |
Maximum files in one upload request |
DEFAULT_TTL_HOURS |
3 |
Expiry used when an upload doesn't set one |
MAX_TTL_HOURS |
unset (no maximum) | Longer requested expiries are capped to this |
ALLOW_PERMANENT |
true |
Whether uploads may be marked permanent |
HASH_LENGTH |
13 |
Characters in generated file IDs (4–64, lowercase base32). Existing files keep their IDs |
MAX_STORAGE_MB |
unset (no cap) | Total size of all stored files. Uploads and pastes that would exceed it fail with 507 |
UPLOAD_DIR (default ./uploads) is read from the process environment, not .env. It holds the uploads plus files.json and urls.json.
Use HTTP Basic Auth with the configured credentials and the file field:
curl -u username:password -F 'file=@./path/to/file' https://your-host/uploadThe JSON response includes the downloadable URL and a deletion_url for each file. Multiple files are supported with repeated -F file=@... arguments.
The curl command shown in the web UI uses temporary credentials that expire after 5 minutes and only work on /upload, /paste and /paste/<name>.
Optional form fields: ttl_seconds (expiry in seconds, capped at the server maximum), permanent=true, and max_views or burn=true (see View limits).
The response is 200 when at least one file was stored. Files that failed are listed in errors as {filename, error}. When every file fails the status is 400 (validation), 500 (storage) or 507 (MAX_STORAGE_MB reached), and 413 when the request body is too large.
Images and .txt files open in the browser; add ?dl=1 to a file URL to download it instead.
POST /paste stores text and returns a link to a viewer page with line numbers, syntax highlighting (picked from the filename's extension) and a copy button:
curl -u username:password -F 'content=<main.go' -F filename=main.go https://your-host/paste{"success": true, "hash": "…", "filename": "main.go", "url": "https://your-host/p/…", "raw_url": "https://your-host/….go", "deletion_url": "…"}filename defaults to paste.txt. raw_url always serves the paste as plain text. Pastes take the same ttl_seconds, permanent, max_views and burn fields as uploads and count against MAX_FILE_SIZE_MB. Content must be UTF-8. The web UI has a Text tab for this.
Highlighting loads highlight.js from cdnjs (pinned with subresource integrity) and is skipped for pastes over 256 KB.
PUT /paste (or a POST whose Content-Type isn't a form) takes the request body as the paste, so curl -T and pipes work. PUT /paste/<name> sets the filename; options go in the query string. The response is the view URL on one line, with the deletion URL in the X-Deletion-URL header:
curl -u username:password -T main.go https://your-host/paste/ # curl appends the file name
git diff | curl -u username:password -T - https://your-host/paste/changes.diff
dmesg | curl -u username:password -T - 'https://your-host/paste?ttl_seconds=600&max_views=1'https://your-host/p/…
Send Accept: application/json to get the JSON response above instead. Errors are plain text (JSON with that header). Temporary curl credentials work on /paste/<name> too.
The web UI's Text tab can encrypt a paste in the browser before uploading it (AES-GCM, 256-bit key). The server stores only the ciphertext under the name encrypted.txt; the real filename and the text are inside it. The key is in the link's #fragment, which browsers never send to the server, so whoever has the full link can read the paste and the server (or anyone with access to UPLOAD_DIR) can't. The server still sees the paste's size, timing, view count and the uploader's IP. Losing the link means losing the paste.
Clients send the ciphertext as content with encrypted=true: unpadded base64url of iv || ciphertext, where the plaintext is the JSON {"filename": "…", "content": "…"}. Anything that isn't base64url is rejected with 400. The viewer page decrypts with the key from the fragment.
Uploads and pastes sent with max_views=N (1–1000) are deleted once they have been opened N times; burn=true means max_views=1 (burn after reading), and max_views wins when both are set. Opening the link shows a confirmation page saying how many views are left, so chat link previews and prefetchers don't use them up; the file is served (whole, without range requests) only when the button is pressed. Each press uses one view, even when several arrive at once. Scripts can skip the page with a POST: curl -X POST -o out https://your-host/<file>.
Files without a limit count their views too: every GET of the file or its /p/ page, except the owner's (requests with the session cookie, Basic Auth or the API key, so previews from the web UI don't count) and range requests that don't start at byte 0 (the later chunks of one video or download). Encrypted pastes count the viewer's fetch of the ciphertext, not the page load.
Every upload response includes a deletion_url (/d/<id>?token=…). Opening it shows a confirmation page, and confirming deletes the file without logging in. Files uploaded before this feature don't have one.
PATCH /api/files/<id> changes when a file expires, counted from now:
curl -u username:password -X PATCH -d '{"ttl_seconds": 86400}' https://your-host/api/files/<id>
curl -u username:password -X PATCH -d '{"permanent": true}' https://your-host/api/files/<id>ttl_seconds is capped at MAX_TTL_HOURS; permanent requires ALLOW_PERMANENT. The file list in the web UI has a button for this.
POST /shorten takes {"url": "...", "custom_code": "optional", "ttl_seconds": 3600}. Links never expire unless ttl_seconds is set; MAX_TTL_HOURS doesn't apply to them. Expiring responses include expires_at.
Each link counts its clicks: GETs of /s/<code>, except the owner's.
GET /api/files and GET /api/urls list what is stored:
{"success": true, "files": [{"hash": "…", "filename": "main.go", "extension": ".go", "upload_time": "…", "permanent": false, "ttl_seconds": 10800, "remaining_ttl": 10750, "size": 1234, "views": 3, "max_views": 0, "paste": true, "encrypted": false, "url": "/….go", "view_url": "/p/…"}]}
{"success": true, "urls": [{"short_code": "…", "original_url": "https://…", "created_time": "…", "expires_at": "…", "clicks": 5}]}max_views is 0 for files without a view limit; expires_at is left out for links that never expire.
GET /api/stats summarises storage:
{"success": true, "files": 12, "pastes": 4, "urls": 3, "bytes_used": 52428800, "max_storage_bytes": 1073741824, "next_expiry": "2026-09-28T01:00:00Z"}files doesn't include pastes. max_storage_bytes is null without MAX_STORAGE_MB, and next_expiry (the soonest file or link expiry) is null when nothing expires.
POST /api/upload accepts chibisafe upload requests, so ShareX configs, browser extensions and scripts made for chibisafe work when pointed at teacup. Set API_KEY and send it as x-api-key:
curl -H 'x-api-key: your-api-key' -F 'file[]=@./shot.png' https://your-host/api/upload{"name": "c2e4jfzcc2iny.png", "uuid": "c2e4jfzcc2iny", "url": "https://your-host/c2e4jfzcc2iny.png", "thumb": "", "deletion_url": "https://your-host/d/c2e4jfzcc2iny?token=…"}Each request uploads exactly one file. Errors use chibisafe's shape: {"statusCode": 413, "error": "Request Entity Too Large", "message": "..."}. Uploads use the default expiry, and the optional ttl_seconds, permanent, max_views and burn fields also work here. The albumuuid header is ignored. Chunked uploads (chibi-* headers) are rejected with 400. deletion_url is a teacup addition.
A ShareX custom uploader (.sxcu) for teacup:
{
"Version": "14.0.0",
"Name": "teacup",
"DestinationType": "ImageUploader, FileUploader",
"RequestMethod": "POST",
"RequestURL": "https://your-host/api/upload",
"Headers": { "x-api-key": "your-api-key" },
"Body": "MultipartFormData",
"FileFormName": "file[]",
"URL": "{json:url}",
"DeletionURL": "{json:deletion_url}"
}ShareX shows the deletion URL in its history (right-click → Open → Deletion URL).
For ShareX's text uploads, a second uploader that creates pastes:
{
"Version": "14.0.0",
"Name": "teacup paste",
"DestinationType": "TextUploader",
"RequestMethod": "POST",
"RequestURL": "https://your-host/paste",
"Headers": { "x-api-key": "your-api-key" },
"Body": "MultipartFormData",
"Arguments": { "content": "{input}", "filename": "{filename}" },
"URL": "{json:url}",
"DeletionURL": "{json:deletion_url}"
}GET /api/capabilities is public and returns the server's upload limits, so clients can validate before uploading:
curl https://your-host/api/capabilities{
"apiVersion": 1,
"maxFileSizeBytes": 104857600,
"maxFilesPerRequest": 20,
"defaultTtlSeconds": 10800,
"maxTtlSeconds": null,
"permanentAllowed": true,
"uploadFields": ["file", "files"],
"maxStorageBytes": null,
"maxViewsLimit": 1000
}maxTtlSeconds and maxStorageBytes are null when there is no maximum. maxViewsLimit is the largest accepted max_views. apiVersion increases only when the upload request or response format changes. Responses may be cached for 60 seconds.

