Guest posting
No account needed. A guest paste is created from the browser or the form endpoint and is handed a one-time manage link (?k= token) that is the only way to delete it later.
Self-hosted paste service
PasteBox is a small, complete paste service: create a paste in the browser or over the API, choose who can read it, set an expiry, and let the database forget it when the time comes. Flask, PostgreSQL 16 and Docker Compose on the inside, one static showcase page on the outside. No third-party requests, no analytics, no build step.
Features
Each card below is implemented in the running service, not a mock-up. The demos further down drive the same rules in the browser so you can see how exposure, expiry and deletion actually behave before you self-host.
No account needed. A guest paste is created from the browser or the form endpoint and is handed a one-time manage link (?k= token) that is the only way to delete it later.
Sign in to own your pastes: /pastes lists everything you created, at every exposure level, and each account carries its own regenerable API key.
Pick the audience per paste. Public is listed in the archive and on your profile, unlisted is readable by anyone holding the link, and private answers 403 to everyone except the owner.
Nine codes from ten minutes to a year, or never. The paste stops being served the moment expires_at passes, and a cleanup thread deletes the row about five minutes later.
Set a paste password and every read route answers 401 until the visitor unlocks it in that session. It is a gate in front of the content, never encryption of it.
The first successful read returns the content and marks the paste for deletion. Every later request is a 404 because the row is gone, not just hidden.
Every paste has a flat view beside the rendered one: /<slug>/raw serves the exact bytes as text/plain; charset=utf-8 with nosniff, and a download endpoint sends the same bytes as an attachment.
28 syntax slugs, from Bash to YAML, mapped to highlight.js language ids and highlighted in the browser from a vendored copy of the library. Plain text is the default.
A public, paginated archive of public pastes at /archive with ?page=, ?q= and ?syntax= filters. Unlisted and private pastes never appear in it.
Every account has a page at /u/<username> listing the pastes that account chose to make public, with views per paste.
Create, read, list and delete over JSON at /api/v1, authenticated with a per-user X-API-Key. Writes require the key; reading public and unlisted pastes does not.
The first admin comes from flask create-admin; from there an operator can moderate the instance, including deleting any paste via /admin/paste/<slug>/delete.
Interactive demos
These stages are scripted in the browser, but the rules they show are the rules the server enforces. Nothing here sends a request anywhere; the copy is written from the service that is actually running.
A guest creating a paste in the browser form: title, content, exposure and expiry, then the share link that comes back.
Scripted in the browser: the form mimics the real create page, and the slug and URL in the result chip are the same shape the service returns.
k3f9d2xa
https://paste.example.com/k3f9d2xa
Three exposure levels against three kinds of reader, with the answer each route gives for the selected combination.
public
Listed in /archive and on /u/<username>. Readable by anyone, signed in or not; it is meant to be found.
unlisted
Readable by anyone who has the link, and by nobody who does not. It never appears in the archive or on a profile. The slug is the whole secret.
private
Owner-only. Every read route answers 403 for anonymous visitors and for signed-in strangers, and the paste is invisible in every listing. Private is an access rule, not encryption: the operator can read the database.
| Route | Path | Result for this reader |
|---|---|---|
| HTML viewbrowser | /<slug> | 200 |
| Raw viewtext/plain | /<slug>/raw | 200 |
| Downloadattachment | /<slug>/download | 200 |
| API metadataJSON | /api/v1/paste/<slug> | 200 |
| Archive listing/archive | /archive | 200 |
The same paste as a highlighted page and as the exact bytes the raw endpoint serves, with the response headers that come with it.
services:
web:
build: .
ports:
- "127.0.0.1:8096:8096"
environment:
SITE_NAME: PasteBox
services:
web:
build: .
ports:
- "127.0.0.1:8096:8096"
environment:
SITE_NAME: PasteBox
Nine expiry codes, a live countdown, and what cleanup does once the deadline has passed.
One paste, two reads: the first gets the content, the second gets nothing at all.
Combine it with a password and a short expiry for one-time secrets.
A locked paste, a wrong password, then the right one and the content for the rest of the session.
The content is stored as usual, but every read route answers 401 until the password is entered. The unlock sets a session cookie that lasts for the session only.
Wrong password: 401 and the form again. Right password: 200 and the content is rendered for the rest of the session.
One paste body, eight of the 28 supported languages, highlighted in the browser with the vendored copy of highlight.js.
from flask import Flask
app = Flask(__name__)
@app.get("/health")
def health():
return {"status": "ok", "db": "ok"}
if __name__ == "__main__":
app.run(port=8096)
The sample cycles every four seconds until you pick a language; the caption always names the highlight.js language id the paste maps to.
Five endpoints plus the failure case nobody plans for, with the request, the response and the status codes you will actually meet.
POST /api/v1/paste
{"ok": true}
A page of public pastes with a search box and syntax chips, filtered live the way /archive filters when you submit.
Sign up, sign in, and the pastes page that a signed-in owner actually sees.
No verification mail is sent: the instance sends no mail at all. The email is an identifier, not a recovery channel.
Sign-in attempts are capped at 10 per hour per IP; past that the login form answers 429. The API key on /account is what the JSON API expects.
/pastes lists everything you own at every exposure level, with the delete control beside it.
API
Everything the browser does, the API does too: create, read metadata, fetch the raw body, list your pastes and delete one. Requests are form-encoded, responses are JSON, and write access is a per-user key.
| Method | Path | Auth | Result |
|---|---|---|---|
| POST | /api/v1/paste | X-API-Key | 200 with slug, url, raw_url, syntax, exposure and expires_at. 401 without a key, 413 past 512 KB, 429 past the hourly limit. ?format=text returns just the URL. |
| GET | /api/v1/paste/<slug> | optional | 200 with metadata (title, syntax, exposure, views, size, line count, timestamps). 403 when private, 404 when unknown or expired. |
| GET | /api/v1/raw/<slug> | optional | 200 with the body as text/plain; charset=utf-8. 401 while a password-protected paste is locked, 403 when private. |
| GET | /api/v1/mine | X-API-Key | 200 with your pastes at every exposure level. Paginated with ?page= and ?limit= (max 100). 401 without a key. |
| DELETE | /api/v1/paste/<slug> | X-API-Key | 200 with {"ok": true, "deleted": "<slug>"}. 403 when the paste is not yours, 404 when it is already gone. |
| GET | /health | none | 200 when the app and the database are up, 503 when the database is down. Useful as a container or uptime probe. |
curl -X POST https://paste.example.com/api/v1/paste \
-H "X-API-Key: pb_your_key_here" \
--data-urlencode "content=$(cat deploy.yml)" \
--data-urlencode "title=deploy notes" \
--data-urlencode "syntax=yaml" \
--data-urlencode "exposure=unlisted" \
--data-urlencode "expires=1D"
The key comes from /account and can be regenerated there at any time. The running instance documents every parameter and error at /api-docs.
Architecture
PasteBox is two containers. Caddy terminates TLS and reverse-proxies to a gunicorn process running the Flask app; PostgreSQL holds users, pastes and rate-limit events and is the only thing with a volume.
The app is never published directly. gunicorn binds 0.0.0.0:8096 inside the container, and compose maps that to 127.0.0.1:8096 on the host: a loopback address that is not reachable from the internet, and not reachable from another machine either. Caddy is the only path in, and it holds the TLS certificate.
Limits
Every limit below is a configuration value with a default in .env.example, so a self-hosted instance can move any of them.
Counted per IP address inside a rolling 60-minute window (GUEST_PASTE_LIMIT, PASTE_RATE_WINDOW_MINUTES). Past it, creation answers 429.
The same window applied per account instead of per IP (USER_PASTE_LIMIT). Signed-in creation is six times roomier than guest creation.
Enforced on the request as well as the stored body (MAX_CONTENT_KB). A larger body is rejected with 413 before anything is written.
Per IP address (LOGIN_ATTEMPT_LIMIT), so a password guesser runs out of tries long before a password does.
Rate-limited responses are 429 with a retry hint; every limit is counted from a row in rate_events, so it survives a restart.
Expired and burned pastes are deleted by a thread that runs every 300 seconds (CLEANUP_INTERVAL_SECONDS), so a dead slug returns 404 within about five minutes.
Deploy
Clone the repository, fill in .env, build and start the two containers, initialise the schema and create the first admin. Everything runs from the project root on the host.
git clone https://github.com/jlaiii/pastebox.git
cd pastebox
cp .env.example .env
# set SECRET_KEY, POSTGRES_PASSWORD and SITE_URL in .env
docker compose build web
docker compose create db web && docker compose start db web
docker compose ps
docker compose exec web flask init-db
docker compose exec web flask create-admin
docker compose exec web flask cleanup
Secrets stay out of the image. .env holds the database password, the session key and the instance settings, is read at runtime, and is never committed. The app itself listens on a loopback port (127.0.0.1:8096) behind Caddy, which is the only container answering on 443. On the host, curl -s http://127.0.0.1:8096/health is the quickest way to confirm the stack is up.
Everyday commands once it is running: docker compose logs -f web, docker compose restart web, docker compose down (keeps the volume) and docker compose down -v (deletes the database).
FAQ
Straight answers, including the ones about what the operator can and cannot see.
Yes. This is a small self-hosted instance: no ads, no paid tier, no analytics and no third-party scripts anywhere on the page. The limits in the previous section (512 KB per paste, ten guest pastes an hour, sixty for an account) exist to keep one server comfortable for everybody, not to sell an upgrade.
Yes, if the one-time manage link was kept. Guest pastes are created without an account, so the service hands back a link carrying a ?k= token exactly once, right after creation. That link is the only way to delete the paste later. Lose it and the paste stays until its expiry passes and cleanup removes it.
No. A paste is immutable, for everyone: its owner, an admin and the person who wrote it. There is no edit route, and that is deliberate, because a link that once pointed at one body should never point at another. To change something, create a new paste and delete the old one.
Only the signed-in owner, through the app. Everyone else, anonymous or signed in, gets 403 from every read route, and the paste is invisible in every listing. Private is an access rule, not encryption: the content is stored on the server, so whoever operates that server can read it in the database. Never paste a private key, a password or a live token anywhere you do not control.
Because the instance sends no mail at all: no verification, no password reset, no notifications. Reaching for a mail provider would mean handing a third party a piece of the service and giving the operator another secret to keep. Sign-up asks for a username, an email and a password, and treats the email as an identifier rather than a recovery channel.
512 KB per paste, ten guest pastes per hour per IP, sixty pastes per hour for a signed-in account and ten sign-in attempts per hour per IP. Anything past those answers 429 with a retry hint, and an oversized body is refused with 413 before it is stored. All four are variables in .env on a self-hosted instance.
Pick one of nine codes (10M, 1H, 1D, 1W, 2W, 1M, 6M, 1Y or N for never) and the paste stores both the code and the exact timestamp it points at. Once that timestamp passes the paste stops resolving, and the cleanup thread deletes the row within about five minutes, after which the slug is a plain 404. Burn-after-read pastes go through the same cleanup path.
For writing, yes: creating, listing your pastes and deleting require the per-user X-API-Key that lives on /account, and a request without one is answered with 401. Reading is open: public and unlisted pastes can be fetched as metadata or as raw text with no key at all, while private pastes stay 403 for everyone but their owner.
Clone https://github.com/jlaiii/pastebox, copy .env.example to .env and fill in the session key and the database password, then docker compose build web and docker compose create db web && docker compose start db web. Initialise the schema with flask init-db and make the first administrator with flask create-admin, both run through docker compose exec web. Point Caddy at the loopback port and the instance is yours; the README walks through the same steps with the Caddy snippet included.