+--------------------------------+
| o o o |
| |
| $ echo 'hi' | openpaste up |
| > /paste/x7Kf2a9Q _ |
| |
+--------------------------------+
o p e n p a s t e
openpaste is a self-hosted pastebin that treats the terminal as a first-class client. You can
paste code in the browser, pipe the output of a command straight into it with curl, or upload
a binary and hand someone a download link β all against the same endpoint.
The backend is a single Rust binary built on axum and sqlx. Text pastes live in the
database; anything that isn't valid UTF-8 is stored as a blob on the local filesystem or in S3.
The database is chosen at runtime from DATABASE_URL, so the same binary runs on SQLite for a
personal instance and on PostgreSQL for a shared one.
The frontend is server-rendered HTML with htmx. The templates and their
CSS live in assets/web/ as plain files and are embedded into the binary at compile time, so
there is no build step and no Node.js β edit the HTML, rebuild, done. If you don't want a UI at
all, run --headless (or build without the frontend feature) and the service answers plain
text to curl and nothing else.
- Share by URL β every paste is available at
/paste/:id, and/paste/:id/rawreturns the exact bytes with no HTML around them. - Pipe from the terminal β
echo 'test' | curl --data-binary @- https://example.comreturns the URL on stdout, ready to be copied or piped further. - Binary uploads β non-UTF-8 content is detected automatically and served from
/paste/:id/downloadas an attachment, with the original filename and a guessed content type. - Pluggable storage β blobs go to the local filesystem or to any S3-compatible bucket
(AWS, MinIO, R2), selected with
STORAGE_DRIVER. - Pluggable database β SQLite or PostgreSQL, selected by the scheme in
DATABASE_URL. - Configurable size limit β
MAX_UPLOAD_BYTES(100 MiB by default) is enforced on the request body, so oversized uploads are rejected with413before being buffered. - Headless mode β API-only operation for servers with no web UI.
- Built-in CLI β the same binary is also a client:
openpaste upandopenpaste get. - Runs anywhere β Docker image, Docker Compose stack, or a systemd unit on a plain VPS.
| Layer | Technology |
|---|---|
| Backend | Rust 2021, axum 0.8, tokio |
| Frontend | Server-rendered HTML + htmx 2 (assets/web/, embedded via rust-embed) |
| Database | SQLite or PostgreSQL, via sqlx Any driver |
| Blob storage | Local filesystem or S3, via object_store |
| CLI | clap 4 + reqwest |
| Packaging | Docker, Docker Compose, systemd |
- Rust 1.80+ (
cargo) β tested on 1.98 - Git
- Optional: Docker 24+ with Compose v2, or PostgreSQL 14+ for a non-SQLite instance
git clone https://github.com/Im-Fran/openpaste.git
cd openpastecp .env.example .env| Variable | Description | Default |
|---|---|---|
BIND |
Address the server listens on | 0.0.0.0:8080 |
BASE_URL |
Public URL used to build the links handed back to clients | http://localhost:8080 |
DATABASE_URL |
sqlite://... or postgres://... |
sqlite://./data/openpaste.db?mode=rwc |
STORAGE_DRIVER |
Where binaries are stored: local or s3 |
local |
STORAGE_PATH |
Blob directory when STORAGE_DRIVER=local |
./data/blobs |
S3_BUCKET |
Bucket name β required when STORAGE_DRIVER=s3 |
β |
S3_PREFIX |
Key prefix inside the bucket | openpaste |
AWS_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_ENDPOINT |
Standard AWS credentials; AWS_ENDPOINT points at MinIO / R2 / other S3-compatible services |
β |
MAX_UPLOAD_BYTES |
Maximum upload size in bytes | 104857600 (100 MiB) |
TLS_CERT |
PEM certificate chain β set together with TLS_KEY to serve HTTPS directly |
β |
TLS_KEY |
PEM private key for TLS_CERT |
β |
TLS_RELOAD_SECS |
How often the PEM files are re-read, so renewals apply without a restart; 0 disables it |
3600 |
HTTP_REDIRECT_BIND |
Extra plain-HTTP listener that 308s everything to BASE_URL |
β |
HEADLESS |
true to disable the web UI |
false |
RUST_LOG |
Log filter | openpaste=info |
cargo run -- serveOpen http://localhost:8080.
Set TLS_CERT and TLS_KEY (or pass --tls-cert / --tls-key) and openpaste terminates TLS
itself β no reverse proxy needed. Both must be set, otherwise startup fails.
BIND=0.0.0.0:443 BASE_URL=https://paste.example.com \
TLS_CERT=/etc/openpaste/tls/fullchain.pem TLS_KEY=/etc/openpaste/tls/privkey.pem \
openpaste serveBinding to 443 needs privileges: the bundled systemd unit grants
AmbientCapabilities=CAP_NET_BIND_SERVICE, and under Docker just map -p 443:8080 instead.
Leave both unset to serve plain HTTP behind nginx or Caddy.
Point TLS_CERT/TLS_KEY at your Let's Encrypt fullchain.pem / privkey.pem. The files are
re-read every TLS_RELOAD_SECS (1 h by default), so a certbot renew applies on its own β no
restart, no deploy hook. A half-written certificate is logged as a warning and the current one
stays in use.
To also answer plain HTTP, add HTTP_REDIRECT_BIND=0.0.0.0:80: every request gets a 308 to
BASE_URL with the path and query preserved. The target is built from BASE_URL rather than
the Host header, so it cannot be turned into an open redirect. It requires TLS and an
https:// BASE_URL β otherwise startup fails instead of looping clients.
BIND=0.0.0.0:443 HTTP_REDIRECT_BIND=0.0.0.0:80 BASE_URL=https://paste.example.com \
TLS_CERT=/etc/openpaste/tls/fullchain.pem TLS_KEY=/etc/openpaste/tls/privkey.pem \
openpaste serveFor frontend work, edit the templates in assets/web/ (layout.html, new.html,
view_text.html, view_binary.html, style.css) and re-run cargo run -- serve.
Paste text into the editor and press create paste, or drop a file anywhere on the page.
# Pipe anything into it; the URL comes back on stdout
echo 'test' | curl --data-binary @- http://localhost:8080
# Send a file, keeping its name (used for the download filename and content type)
curl -T report.pdf http://localhost:8080/report.pdf
# Read it back
curl http://localhost:8080/paste/x7Kf2a9Q/rawA shell function makes it a one-word command:
# ~/.bashrc or ~/.zshrc
export OPENPASTE_SERVER=https://paste.example.com
paste() { curl -sf --data-binary @- "$OPENPASTE_SERVER"; }
# then:
git diff | paste
journalctl -u nginx -n 200 | pasteopenpaste up report.pdf # upload a file
git log --oneline | openpaste up # upload stdin
openpaste get x7Kf2a9Q # print the raw content to stdout
openpaste get x7Kf2a9Q > out.bin # binaries stream through unchangedopenpaste up prints the URL on stdout and, when stderr is a terminal, an ASCII
QR code of that URL on stderr β scan it with a phone. Piping or redirecting keeps
the output clean:
openpaste up report.pdf | pbcopy # only the URL, no QR--server (or OPENPASTE_SERVER) points the client at your instance; it defaults to
http://localhost:8080.
| Method | Path | Description |
|---|---|---|
POST |
/ or /api/pastes |
Create a paste from the raw request body. Optional X-Filename header. Returns the URL as plain text, or JSON when Accept: application/json. |
PUT |
/:filename |
Same, with the filename taken from the path (this is what curl -T does). |
GET |
/paste/:id |
Web UI for the paste (raw content in headless mode). |
GET |
/paste/:id/raw |
Raw content, no attachment header. |
GET |
/paste/:id/download |
Raw content as Content-Disposition: attachment. |
GET |
/api/pastes/:id |
Paste metadata as JSON (plus the content, for text pastes). |
GET |
/healthz |
Liveness probe. |
cargo build --releaseThe binary lands in target/release/openpaste with the UI embedded β copy that one file to your
server.
To build without the frontend (smaller binary):
cargo build --release --no-default-featuresPublished images live at ghcr.io/im-fran/openpaste. Pick latest for the newest release or
pin a version tag (0.3.0, 0.3); dev tracks the development branch:
docker run -p 8080:8080 -v openpaste-data:/var/lib/openpaste \
-e BASE_URL=https://paste.example.com ghcr.io/im-fran/openpaste:latestOr build it yourself β the image builds the UI, compiles the binary and ships a slim Debian runtime:
docker build -t openpaste .
docker run -p 8080:8080 -v openpaste-data:/var/lib/openpaste \
-e BASE_URL=https://paste.example.com openpasteIt defaults to SQLite and local blob storage under /var/lib/openpaste, so a single volume
keeps everything.
docker-compose.yml brings up openpaste alongside PostgreSQL 17:
cp .env.example .env # set BASE_URL and POSTGRES_PASSWORD
docker compose up -dPoint STORAGE_DRIVER=s3 plus the S3_* / AWS_* variables at a bucket if you don't want the
blobs on the host volume.
sudo useradd -r -d /var/lib/openpaste -m openpaste
sudo install -m755 target/release/openpaste /usr/local/bin/openpaste
sudo install -Dm640 .env.example /etc/openpaste/openpaste.env # then edit it
sudo install -m644 openpaste.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now openpaste
sudo systemctl status openpasteThe unit runs as an unprivileged openpaste user with ProtectSystem=strict and only
/var/lib/openpaste writable. Either put nginx or Caddy in front for TLS or set
TLS_CERT/TLS_KEY and bind to 443 directly, and make sure BASE_URL matches the public
hostname β it is what the returned links are built from.
cargo test # unit tests
./scripts/smoke.sh # end-to-end: text, binary, 404, size limit, CLI round-tripThe smoke script starts a throwaway server on a temporary SQLite database and checks that a random 4 KiB blob survives an upload/download round-trip byte for byte.
Contributions are welcome.
- Fork the repo
- Create a branch:
git checkout -b feat/your-feature - Make sure
cargo testand./scripts/smoke.shpass - Commit:
git commit -m "feat: add your feature" - Push and open a PR
MIT β see LICENSE.