README
¶
hostebin
Upload a bundle of files, get a URL you can hand to a person.
Your agent just produced a 400-line HTML report. Pasting it into chat is useless.
hostebin up report.html returns a link that opens in a browser.
$ hostebin up report.html
https://paste.example.com/b/9f2a1c7d40b8e35f/
One static Go binary is both the server and the client. Storage is a directory. Reads are public, every mutation needs a bearer token, and the 128-bit bundle ID in the URL is the read capability.
- Bundles, not files. Upload a page with its images and CSS; relative links work.
- Markdown renders to a styled page;
?raw=1gives you the source. - Agent-shaped output.
upprints exactly one URL on stdout, diagnostics on stderr,--jsonfor everything else. Ready-made agent skills included. - Serve it anywhere. Localhost, a VPS with Let's Encrypt, or a tailnet with
embedded Tailscale — no
tailscaled, noNET_ADMIN.
Install
| Go |
|
|---|---|
| Debian/Ubuntu |
|
| Fedora/RHEL, Alpine |
|
| Binary |
|
| Docker |
|
go install, the release binaries, the Linux packages, and the container image work from the first tagged release.
Archives, checksums.txt, and Linux packages are published for Linux, macOS,
Windows, and FreeBSD on amd64, arm64, 386, and armv7. File names carry no version,
so releases/latest/download/... always resolves to the newest release. Verify a
download with sha256sum -c checksums.txt --ignore-missing.
Quick start
1. Start a server (any machine that both you and your agent can reach):
hostebin serve
On first run it generates a token, stores it in the data directory with mode 0600,
and logs it once.
2. Point the client at it:
export HOSTEBIN_SERVER=http://localhost:8080
export HOSTEBIN_TOKEN="$(cat "${XDG_DATA_HOME:-$HOME/.local/share}/hostebin/token")"
3. Upload:
hostebin up report.html # one page
hostebin up report.html img/chart.png # page plus assets
hostebin up ./site/ # a whole directory
echo '# Status' | hostebin up -n status.md - # from stdin
To persist the settings instead of exporting them every session, put them in
~/.config/hostebin/config.json (see Configuration).
Using it from an agent
The skills/ directory contains two drop-in skills: hostebin teaches
an agent when and how to publish, and beautiful-html teaches it to write a page
worth reading first. For Claude Code:
mkdir -p ~/.claude/skills && cp -r skills/hostebin skills/beautiful-html ~/.claude/skills/
For any other agent, both SKILL.md files are plain Markdown — append them to
AGENTS.md or your system prompt.
The contract that makes this safe to script:
| Behaviour | Detail |
|---|---|
| stdout | Exactly one line: the bundle URL |
| stderr | Every diagnostic, warning, and error |
--json |
Full response: id, url, entry_url, files[], expires_at |
| exit codes | 0 success, 1 usage/config error, 2 network or server error |
| config | HOSTEBIN_SERVER and HOSTEBIN_TOKEN are all an agent needs |
url=$(hostebin up --ttl 7d --title 'Weekly report' report.html) || exit $?
echo "Report: $url"
Iterate on an artifact without invalidating the link you already shared:
hostebin up --id 9f2a1c7d40b8e35f report.html
Commands
hostebin up [flags] <file|directory|->... upload a bundle, print its URL
hostebin ls [flags] list live bundles
hostebin rm [flags] <id> delete a bundle
hostebin user ls|add|rm|disable|enable manage users
hostebin token new|rm rotate or revoke a token
hostebin whoami [--json] show the current identity
hostebin serve [flags] run the server
hostebin version version, commit, build date
up flags:
| Flag | Meaning |
|---|---|
--title TEXT |
Bundle title |
--ttl DURATION |
30m, 12h, 7d, 2w, or never |
--entry PATH |
File served at the bundle root |
--id ID |
Replace an existing bundle in place |
-n, --name PATH |
Name for data read from stdin (required with -) |
--json |
Print the JSON response instead of the URL |
--open |
Open the URL in the system browser |
--quiet |
Suppress optional diagnostics |
--server, --token, --config |
Override the resolved configuration |
Directory uploads strip the directory argument itself and keep the paths below it;
symlinks and non-regular files are refused. --entry picks the root page; otherwise
a single file wins, then index.html, then the first HTML file, then the first
Markdown file, then a generated listing.
Users and access tokens
The token created on first start belongs to the admin user. Admins can create and
delete users, disable or enable accounts, and manage anyone's token. Regular users
can rotate and revoke only their own token.
token new replaces the current one, which stops working immediately.
The new plaintext is printed exactly once:
hostebin user add bob --ttl 90d # stdout is Bob's token
hostebin token new --user bob # rotates Bob's token; stdout is the replacement
hostebin user ls # includes token metadata
hostebin user disable bob
hostebin token rm --user bob # revokes Bob's token
Bundle listing, replacement, and deletion are isolated by owner. A non-owner receives
the same 404 as for an unknown bundle. Admins are the exception: they can replace or
delete any bundle, so abusive content can be removed without deleting its owner. Admin
listing stays scoped by default — hostebin ls shows an admin their own bundles, and
hostebin ls --all adds every other owner's. Regular users get their own view even if
they pass --all.
Reads stay public and unauthenticated. The 128-bit bundle ID is still the read capability. Isolation is an API-surface property: it stops Bob listing, editing, or deleting Alice's bundles. It does not stop him reading one whose URL he has, and it does not change the existing shared-origin caveat.
Configuration
Precedence: flags → HOSTEBIN_* environment → JSON config → defaults. Every long
flag has an environment variable formed by uppercasing it and replacing - with _
(--max-upload → HOSTEBIN_MAX_UPLOAD).
A 0600 config file is created on first use at
${XDG_CONFIG_HOME:-~/.config}/hostebin/config.json (Linux/BSD),
~/Library/Application Support/hostebin/config.json (macOS), or %AppData%\hostebin
(Windows). Override with --config or HOSTEBIN_CONFIG. JSON keys are the long flag
names:
{
"server": "https://hostebin.example.com",
"token": "replace-me",
"host": "",
"port": 8080,
"data": "/home/me/.local/share/hostebin",
"base-url": "",
"tls-addr": ":8443",
"tls-cert": "",
"tls-key": "",
"acme-domain": "",
"acme-email": "",
"tailscale": false,
"funnel": false,
"ts-hostname": "hostebin",
"ts-auth-key": "",
"max-upload": "32MiB",
"max-files": 64,
"default-ttl": "never",
"csp": "",
"bundle-host": ""
}
Persistent data (bundles, users.json, plus the legacy bootstrap token file) lives in
${XDG_DATA_HOME:-~/.local/share}/hostebin on Linux/BSD and <user config dir>/hostebin/data elsewhere.
Running a server
# plain HTTP, the default
hostebin serve --host 0.0.0.0 --port 8080 --data /var/lib/hostebin
# your own certificate, with or without plain HTTP alongside
hostebin serve --port 0 --tls-addr :8443 --tls-cert cert.pem --tls-key key.pem
# automatic public HTTPS; owns :80 (ACME challenges + redirect) and :443
hostebin serve --port 0 --acme-domain files.example.com --acme-email ops@example.com
# embedded Tailscale — no tailscaled, no /dev/net/tun, no NET_ADMIN
HOSTEBIN_TS_AUTH_KEY=tskey-auth-... hostebin serve --port 0 --tailscale
HOSTEBIN_TS_AUTH_KEY=tskey-auth-... hostebin serve --port 0 --funnel # public
Tailscale state persists under $HOSTEBIN_DATA/tsnet; the server tries tailnet HTTPS
first and logs an HTTP fallback if HTTPS is not enabled for the tailnet.
--base-url overrides the URL returned by every listener. Without it, Tailscale and
ACME use their known DNS names and other listeners derive links from the request. Behind
a reverse proxy, forward the original Host and set X-Forwarded-Proto.
Useful server settings: --max-upload (default 32MiB), --max-files (default 64),
--default-ttl (default never), --csp (off disables it). Expired bundles 404
immediately; a sweep at startup and every ten minutes reclaims their files.
Path-based or host-based routing
A bundle can be reached in one of two shapes, chosen once per server:
| Path-based (default) | Host-based (--bundle-host) |
|
|---|---|---|
| URL | https://paste.example.com/b/<id>/ |
https://<id>.paste.example.com/ |
| Configuration | nothing — this is the default | --bundle-host '*.paste.example.com' |
| DNS | one A/AAAA record | wildcard record for *.paste.example.com |
| TLS | anything, including built-in ACME | a wildcard certificate you supply |
| Browser origin | all bundles share one, with the API | one per bundle, none shared with the API |
| Where the id is exposed | URL path only | also DNS queries and TLS SNI |
Use path-based routing — the default — for localhost, a tailnet, a single-user
VPS, or anywhere you cannot get a wildcard certificate. It is the only mode the
built-in listeners can serve on their own: --acme-domain refuses to start
alongside --bundle-host, and Tailscale issues certificates per node, not per
wildcard, so --tailscale and --funnel are effectively path-based too.
Use host-based routing when bundles come from more than one person, or from
agents whose output you do not fully control, and you want the browser to enforce
the separation. Under path-based routing every bundle shares an origin, so a page in
one bundle can read another bundle's localStorage or fetch its files if it knows
that bundle's id, and it shares an origin with /api/v1/*. A distinct origin per
bundle removes that class of problem entirely.
Switching to host-based routing
hostebin serve --port 0 --tls-addr :8443 \
--tls-cert wildcard.pem --tls-key wildcard-key.pem \
--bundle-host '*.paste.example.com'
- Point a wildcard DNS record at the server.
- Obtain a certificate for
*.paste.example.com. Terminate TLS at a reverse proxy holding it (forwarding the originalHostandX-Forwarded-Proto), or pass it to--tls-cert/--tls-key. - Start with
--bundle-host '*.paste.example.com'. The value must begin with*.and the wildcard matches a single label; a bare hostname is rejected at startup.
Nothing changes on the client side: upload responses return the subdomain form, and
existing /b/<id>/… links 301 to it, so links you shared before the switch keep
working. --base-url still controls the scheme and port used to build bundle URLs.
Built-in ACME cannot issue the certificate: autocert implements only http-01 and
tls-alpn-01, while Let's Encrypt issues wildcards only over dns-01. Combining
--bundle-host with --acme-domain is a startup error rather than a silent
fallback — per-host issuance would publish every bundle id to the public Certificate
Transparency logs, defeating the point of an unguessable link.
The trade-off for the origin separation is that the id moves from the URL path into
the hostname, so it becomes visible to DNS resolvers and to anyone on the network
path via TLS SNI. Bundle responses send Referrer-Policy: no-referrer under both
modes, so the id is not handed to third-party origins the page loads from.
Dokploy + Cloudflare
Deploy ghcr.io/readeem/hostebin as a Dokploy application or Compose service, with
a volume on /data.
Path-based needs nothing special: under Domains, add hostebin.example.com
on container port 8080 with HTTPS and the Let's Encrypt certificate provider.
Host-based takes three more steps, because Dokploy has no wildcard-domain field
and Let's Encrypt only issues wildcards over dns-01:
-
Cert resolver. In
/dashboard/traefik, add a DNS-challenge resolver to the global static config, and setCF_DNS_API_TOKENin Traefik's environment to a Cloudflare token with Zone → Zone → Read and Zone → DNS → Edit:certificatesResolvers: cloudflare: acme: email: ops@example.com storage: /etc/dokploy/traefik/dynamic/acme.json dnsChallenge: provider: cloudflare -
Router rule. Add the domain first — Dokploy generates the routing from it, and for an application the Traefik config editor only appears at the bottom of Advanced once one exists. Then widen the rule on both routers and request the wildcard on the
websecureone:rule: Host(`hostebin.example.com`) || HostRegexp(`^[a-z0-9-]+\.hostebin\.example\.com$`) tls: certResolver: cloudflare domains: - main: hostebin.example.com sans: - "*.hostebin.example.com"For a Compose service the routing lives in the generated Traefik labels instead, so edit those on the service — same rule, same resolver:
labels: - "traefik.http.routers.hostebin-websecure.rule=Host(`hostebin.example.com`) || HostRegexp(`^[a-z0-9-]+\\.hostebin\\.example\\.com$`)" - "traefik.http.routers.hostebin-websecure.tls.certresolver=cloudflare" - "traefik.http.routers.hostebin-websecure.tls.domains[0].main=hostebin.example.com" - "traefik.http.routers.hostebin-websecure.tls.domains[0].sans=*.hostebin.example.com"Either way Traefik forwards the original
Hostand setsX-Forwarded-Proto, which is what--bundle-hostneeds to build correct URLs. -
DNS. Add an A record for
*.hostebin.example.compointing at the server as DNS-only (grey cloud). Cloudflare's Universal SSL covers only the apex and first-level subdomains, so a proxied*.hostebin.example.comfails TLS unless you pay for Advanced Certificate Manager. Yourhostebin.example.com(or*.example.com) records can stay proxied.
Finally, set HOSTEBIN_BUNDLE_HOST=*.hostebin.example.com in the service's
environment. All deployments need a redeploy/reload for domain and env changes to take effect.
The .deb, .rpm, and Arch packages install a hardened systemd unit:
sudo systemctl enable --now hostebin
sudo systemctl cat hostebin # data lives in /var/lib/hostebin
Containers
docker run --rm -p 8080:8080 -v hostebin-data:/data ghcr.io/readeem/hostebin
HOSTEBIN_TOKEN=choose-a-secret docker compose --profile plain up
HOSTEBIN_TS_AUTH_KEY=tskey-auth-... HOSTEBIN_TOKEN=choose-a-secret \
docker compose --profile tailscale up
Images are CGO_ENABLED=0 on distroless nonroot, published for linux/amd64 and
linux/arm64. The Tailscale Compose service needs no elevated capabilities.
HTTP API
All /api/v1 routes require Authorization: Bearer TOKEN. Bundle content and
/healthz are public.
curl -sf -H "Authorization: Bearer $HOSTEBIN_TOKEN" \
-H 'X-Hostebin-Filename: report.html' \
--data-binary @report.html \
"$HOSTEBIN_SERVER/api/v1/bundles"
curl -sf -H "Authorization: Bearer $HOSTEBIN_TOKEN" \
-F 'file=@report.html;filename=report.html' \
-F 'file=@img/chart.png;filename=img/chart.png' \
"$HOSTEBIN_SERVER/api/v1/bundles"
| Route | Purpose |
|---|---|
POST /api/v1/bundles |
Create from multipart file parts or a raw body with X-Hostebin-Filename |
PUT /api/v1/bundles/{id}?mode=replace |
Atomically replace a bundle |
PUT /api/v1/bundles/{id}?mode=merge |
Atomically merge named files into a bundle |
GET /api/v1/bundles |
List live bundles |
GET /api/v1/bundles?all=1 |
Admin global view with owners |
DELETE /api/v1/bundles/{id} |
Delete a bundle |
GET /api/v1/whoami |
Show the authenticated user and token label |
GET, POST /api/v1/users |
List or create users (admin) |
PATCH, DELETE /api/v1/users/{id} |
Disable/enable or delete a user (admin) |
PUT /api/v1/users/{id}/token |
Atomically replace the user's token (admin or self) |
DELETE /api/v1/users/{id}/token |
Revoke the user's token (admin or self) |
GET /b/{id}/... |
Read public content; ?raw=1 disables Markdown rendering |
PUT /api/v1/users/{id}/token returns the replacement plaintext once. The previous
token is invalid for the next request. Its {label, ttl} body is optional; sending no
body accepts the defaults.
Raw uploads also accept X-Hostebin-Title, X-Hostebin-Entry, and X-Hostebin-TTL;
multipart uploads use title, entry, and ttl fields.
Content security model
Hosted files are untrusted. The server never uses cookie authentication, sends
X-Content-Type-Options: nosniff, and serves unknown extensions as downloadable
application/octet-stream. Bundle responses carry this default CSP:
default-src 'self' data: blob: https: 'unsafe-inline' 'unsafe-eval'; connect-src 'self' https:; form-action 'none'; frame-ancestors 'none'
Inline scripts/styles and HTTPS CDN assets are intentionally allowed because generated
reports need them, and connect-src permits HTTPS so a report can render live data.
Plain HTTP is deliberately excluded: it keeps a published page from probing the
reader's own network (http://192.168.x.x, http://localhost:11434) from their
browser.
This is not a sandbox. script-src falls back to https:, so a bundle can already
load and execute code from any HTTPS origin, and img-src can carry data outward in a
URL — the CSP limits accidents, not a malicious uploader. The threat model is that
whoever holds the upload token is trusted, and the reader and their network are not.
By default bundles share an origin, so content can reach another bundle whose
unguessable ID it knows. --bundle-host gives each bundle its own origin and removes
that class entirely; see
Path-based or host-based routing.
While bundles share an origin with the API, the API must never accept ambient
credentials. It is bearer-token only for exactly this reason: a cookie session would
let any published page call /api/v1/* with the reader's credentials.
Writes stage in hidden temporary directories before an atomic rename; reads go through
os.OpenRoot, so .. and symlinks cannot escape the storage root.
Treat every bundle URL as a secret-bearing link, and never publish credentials or personal data.
Development
just drives everything:
just # list recipes
just build # ./hostebin, version stamped from the current git tag
just check # gofmt, go vet, go test — the CI gate
just test-all # default, notsnet, and -race test runs
just serve # run the server from source
Version lives in exactly one place, internal/version,
and every build path (justfile, Dockerfile, GoReleaser) overrides it with the same
-ldflags target. A build without ldflags falls back to the module version recorded
by the Go toolchain.
Smaller binary that rejects Tailscale flags:
just build-slim # go build -tags notsnet
Releasing
just tag 0.1.0 # tags v0.1.0 and pushes it
The tag triggers release.yml: GoReleaser builds
every target, attaches archives, checksums, and Linux packages to the GitHub release,
updates the Homebrew/Scoop/AUR manifests, and a second job publishes the container
image to GHCR.
License
MIT © Readeem
Directories
¶
| Path | Synopsis |
|---|---|
|
cmd
|
|
|
hostebin
command
|
|
|
internal
|
|
|
users/storetest
Package storetest contains the acceptance contract shared by user stores.
|
Package storetest contains the acceptance contract shared by user stores. |
|
version
Package version is the single place that carries build identity.
|
Package version is the single place that carries build identity. |