Self-hosted paste service

A pastebin that lives on your own server

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.

  • Flask 3
  • PostgreSQL 16
  • Docker Compose
  • 28 syntax languages
  • Guest pastes with a one-time manage link
  • No external requests, no tracking
curl -X POST /api/v1/paste
waiting types the request, then the JSON response
0 syntax languages
0 expiry options
0 exposure levels
0 KB per paste, maximum
0 passing tests
0 live end-to-end checks

Features

Everything a paste needs, and nothing that phones home

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.

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.

Accounts

Sign in to own your pastes: /pastes lists everything you created, at every exposure level, and each account carries its own regenerable API key.

Public, unlisted and private

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.

Expiry

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.

Password protection

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.

Burn after read

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.

Raw view and download

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.

Syntax highlighting

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.

Archive and search

A public, paginated archive of public pastes at /archive with ?page=, ?q= and ?syntax= filters. Unlisted and private pastes never appear in it.

Public profiles

Every account has a page at /u/<username> listing the pastes that account chose to make public, with views per paste.

REST API

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.

Admin dashboard

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

Ten demos that show how PasteBox really behaves

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.

01

Creating a paste

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.

  • Where it happens: the browser form posts to /; the API creates the same object at POST /api/v1/paste with the same field names.
  • Fields: title, content, syntax, exposure and expires. Exposure is one of public, unlisted or private; expires is one of the nine codes, or N for never.
  • What comes back: the new slug (8 characters) and the absolute URL, plus raw_url, expires_at and the stored syntax in the JSON form.
  • Guests: the manage link with its one-time token is shown once, right after creation. Keep it and you can delete the paste; lose it and the paste stays until it expires.
  • Limits: 512 KB per paste (413 beyond that), 10 guest pastes per hour per IP and 60 per hour for an account.
02

The exposure matrix

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.

200 - public: readable by anyone with the link, and listed in /archive and on the owner profile.
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
  • public is the only level that reaches /archive or a profile page; unlisted pastes are readable but never listed, and the listing route answers 404 for them.
  • private turns every read route into 403 for everyone except the owner. An unknown or already expired slug is 404 instead, so the two cases stay distinguishable.
  • Signed-in strangers see exactly what anonymous visitors see: ownership, not authentication, is what opens a private paste.
  • The owner always sees their own paste through the app, with the management controls on the paste page.
03

Rendered view and raw view

The same paste as a highlighted page and as the exact bytes the raw endpoint serves, with the response headers that come with it.

deploy notes
Rendered GET /k3f9d2xa
deploy notes yaml
services:
  web:
    build: .
    ports:
      - "127.0.0.1:8096:8096"
    environment:
      SITE_NAME: PasteBox
Plain GET /k3f9d2xa/raw
services:
  web:
    build: .
    ports:
      - "127.0.0.1:8096:8096"
    environment:
      SITE_NAME: PasteBox
Response headers from the raw endpoint
Status
HTTP/1.1 200 OK
Content-Type
text/plain; charset=utf-8
X-Content-Type-Options
nosniff
  • Route: GET /<slug>/raw for the browser and GET /api/v1/raw/<slug> for the API. Both return the stored bytes unchanged, as text/plain; charset=utf-8.
  • Headers: X-Content-Type-Options: nosniff keeps a browser from second-guessing the content type, and the download route adds a Content-Disposition attachment header.
  • Copy puts the flat text on the clipboard, never the highlighted markup.
  • Status codes: 200 normally, 401 while a password-protected paste is still locked, 403 when the paste is private and you are not the owner, 404 when the slug is unknown or expired.
04

The expiry lifecycle

Nine expiry codes, a live countdown, and what cleanup does once the deadline has passed.

  • created
  • counting down
  • expired
  • removed by cleanup
Time remaining 00:00 Counted from expires_at; the paste stops being served the moment it passes.
Cleanup expires in 1 day - cleanup deletes it ~5 min after expiry.
  • Codes: 10M, 1H, 1D, 1W, 2W, 1M, 6M, 1Y and N for never. These are the exact strings the API accepts and the ones written to expires_code.
  • Expired is not deleted: a paste past its deadline stops resolving first, then the cleanup thread removes the row. The same thread reaps burn-after-read pastes.
  • Cleanup cadence: it runs inside the web process every 300 seconds (CLEANUP_INTERVAL_SECONDS), so a slug starts returning 404 within about five minutes of expiry. Set DISABLE_SCHEDULER=1 to drive it from cron instead.
  • Early removal: the owner can delete a paste at any time from /pastes, and an admin can delete any paste.
05

Burn after read

One paste, two reads: the first gets the content, the second gets nothing at all.

First read 200 OK
fuse burns while the read is served
Second read 404
waiting for the first read
The content was consumed by the first read, so this request finds nothing: the row is deleted, not hidden.

Combine it with a password and a short expiry for one-time secrets.

  • How to set it: burn_after_read is a boolean on create, available in the browser form and in the API.
  • Behaviour: the first successful read returns the content and schedules the paste for deletion; every later read is a 404.
  • Crash safety: if the request dies between serving and deleting, the cleanup thread picks the paste up on its next pass, within about five minutes.
  • Warning worth repeating: a preview bot, a link scanner or a curious proxy that fetches the URL counts as the first read.
06

Password protection

A locked paste, a wrong password, then the right one and the content for the rest of the session.

This paste is locked

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.

locked - enter the paste password to unlock

Wrong password: 401 and the form again. Right password: 200 and the content is rendered for the rest of the session.

  • Wrong and right: the unlock form posts the password; a mismatch returns 401, a match sets the session flag and the paste renders.
  • Every route is gated: while the paste is locked, the HTML view, the raw view and the API metadata answer 401 as well.
  • It is a gate, not encryption: the password is stored as a hash and checked server-side, but the content itself is readable in the database by the operator.
  • Good default: password plus private plus a short expiry is a sane shape for a shared secret.
07

Syntax highlighting

One paste body, eight of the 28 supported languages, highlighted in the browser with the vendored copy of highlight.js.

paste body highlight.js language id: python
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.

  • 28 slugs: plain text, bash, c, c++, c#, css, diff, docker, go, html, ini, java, javascript, json, kotlin, lua, markdown, nginx, perl, php, powershell, python, ruby, rust, sql, swift, typescript and yaml.
  • Two names per slug: the stored slug (for example html) and the highlight.js language id (for example xml) are kept apart, so the highlighted view stays correct even when the names differ.
  • No CDN: highlight.js and its extra language packs are vendored into the page, which is why this showcase makes no external requests at all.
  • Where else the slug matters: it drives the highlight colour of the paste, the syntax chip in /archive and the ?syntax= filter.
08

The API explorer

Five endpoints plus the failure case nobody plans for, with the request, the response and the status codes you will actually meet.

Request picked case
POST /api/v1/paste
Response JSON
{"ok": true}
  • Auth: a per-user X-API-Key header (or the api_dev_key form field). Writes always require it; reading public and unlisted pastes does not.
  • Status codes: 200 on success, 401 without a key or with a bad one, 403 when the paste is not yours or is private, 404 when the slug is unknown or expired, 413 past 512 KB and 429 past the hourly limit.
  • Shortcut: ?format=text on create returns just the URL, which keeps shell pipelines simple.
  • Listing: GET /api/v1/mine?page=1&limit=20 returns your pastes at every exposure level; the page size caps at 100.
  • Reference: the running instance documents every field and error at /api-docs.
09

The public archive

A page of public pastes with a search box and syntax chips, filtered live the way /archive filters when you submit.

10 of 10 shown
  • deploy notes services: web: build: . ports: 127.0.0.1:8096 yaml unlisted 42 views
  • nginx reverse proxy server { listen 443 ssl; proxy_pass 127.0.0.1:8096; } nginx public 318 views
  • backup script pg_dump -U pastebox -d pastebox -Fc > pastebox.dump bash public 96 views
  • schema sketch select slug, syntax, views from pastes order by created_at desc sql public 57 views
  • api client requests.post(url, headers={"X-API-Key": key}, data=payload) python public 204 views
  • compose stack db: image: postgres:16 web: build: . depends_on: db yaml public 151 views
  • release checklist - run the tests - rebuild the image - check /health - tag the release markdown public 73 views
  • rate limit notes guest 10/hour per IP, account 60/hour, login 10/hour, all 429 text public 88 views
  • health probe curl -s http://127.0.0.1:8096/health | jq .status bash public 112 views
  • deploy notes copy FROM python:3.12-slim, gunicorn -k gthread -w 3 -b 0.0.0.0:8096 dockerfile public 19 views
  • Route: GET /archive, public and paginated, with ?page=, ?q= and ?syntax= handled server-side. The demo filters a fixed page in the browser so it works offline.
  • What is listed: public pastes only. Unlisted pastes stay reachable by link and private pastes stay owner-only, so neither ever shows up here.
  • Search: matches titles and content case-insensitively; the syntax chips map onto the 28 stored slugs.
  • Profiles: /u/<username> is the same filter applied to one account.
10

Accounts in three steps

Sign up, sign in, and the pastes page that a signed-in owner actually sees.

  1. 1 Sign up
  2. 2 Sign in
  3. 3 My pastes
  • Sign up: /signup takes a username, an email and a password. Nothing is mailed and nothing is verified, by design.
  • Sign in: /login, rate-limited to 10 attempts per hour per IP, after which the form answers 429.
  • Ownership: /pastes shows every paste you created at every exposure level, each with its expiry and views.
  • Admin: flask create-admin makes the first administrator, who can then delete any paste on the instance.

API

A small JSON API for the same service

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.

PasteBox HTTP API endpoints
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.
Create a paste with curl
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

One request, five hops, one stateful part

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.

Browser An HTTPS request from a visitor, or a curl call from a script. This showcase page is static and never touches the service.
Caddy The only container that answers on 443. It holds the certificate, terminates TLS, sets the security headers and proxies to the app.
gunicorn Three gthread workers on 0.0.0.0:8096 inside the compose network. The port is mapped to the host loopback interface only.
Flask Routing, sessions, the exposure and expiry rules, the paste views and the /api/v1 blueprint. The cleanup thread runs in this process.
PostgreSQL 16 The only stateful container: users, pastes and rate_events, on a volume, with the password living in .env.
Nodes light up as the packet travels, left to right.

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.

Docker Docker Compose Caddy gunicorn (gthread) Flask 3 PostgreSQL 16 highlight.js (vendored)

Limits

The numbers that keep a small instance healthy

Every limit below is a configuration value with a default in .env.example, so a self-hosted instance can move any of them.

10 Guest pastes per hour

Counted per IP address inside a rolling 60-minute window (GUEST_PASTE_LIMIT, PASTE_RATE_WINDOW_MINUTES). Past it, creation answers 429.

60 Account pastes per hour

The same window applied per account instead of per IP (USER_PASTE_LIMIT). Signed-in creation is six times roomier than guest creation.

512 KB per paste, maximum

Enforced on the request as well as the stored body (MAX_CONTENT_KB). A larger body is rejected with 413 before anything is written.

10 Sign-in attempts per hour

Per IP address (LOGIN_ATTEMPT_LIMIT), so a password guesser runs out of tries long before a password does.

429 What a limit looks like

Rate-limited responses are 429 with a retry hint; every limit is counted from a row in rate_events, so it survives a restart.

5 min Cleanup interval

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

Three commands to your own pastebin

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.

1. Configure
git clone https://github.com/jlaiii/pastebox.git
cd pastebox
cp .env.example .env
# set SECRET_KEY, POSTGRES_PASSWORD and SITE_URL in .env
2. Build and start
docker compose build web
docker compose create db web && docker compose start db web
docker compose ps
3. Initialise and administer
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

Questions the demos usually raise

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.