PasteHere Documentation

Installation

PasteHere is a self-hosted pastebin that runs entirely on Cloudflare's edge. There is no server to provision, no Docker container to babysit, and no database to tune — Workers serves the app, D1 stores paste metadata, and R2 stores the paste bodies. You pay Cloudflare nothing on the free plan for low traffic, and the Workers paid plan ($5/mo) covers millions of requests.

Get the source

Prerequisites

RequirementDetails
Cloudflare accountFree tier works for personal use; Workers Paid ($5/mo) for higher limits
Node.js20.x or newer — for running Wrangler locally
Wrangler CLIv3.60+ — installed via the included package.json
macOS / Linux / WSL2Developed and tested on macOS Apple Silicon; the deploy commands work cross-platform

First deploy

  1. Unpack the archive and run npm install.
  2. Run npx wrangler login and authenticate your Cloudflare account.
  3. Create the backing resources (script included): npm run db:init provisions the D1 database and R2 bucket and writes their IDs into wrangler.toml.
  4. Run the schema migration: npm run db:migrate — applies schema/0001_init.sql to the new D1 instance.
  5. Deploy: npm run deploy. Wrangler publishes the Worker; your pastebin is live at https://pastehere.<your-subdomain>.workers.dev within seconds.
Optional: custom domain. In the Cloudflare dashboard, open the Worker → Triggers → Custom Domains, add paste.yourdomain.com. Cloudflare provisions the certificate automatically — no DNS wrangling.

Quick Start

1. Create your first paste

  1. Open https://paste.yourdomain.com (or the workers.dev URL above).
  2. Paste your snippet into the editor. Syntax highlighting and line numbers render live.
  3. Pick a language (auto-detect handles the common ones) and choose an expiration: 10 min, 1 hour, 1 day, 1 week, 1 month, or Never.
  4. Click Create. The URL https://paste.yourdomain.com/<slug> is returned — share it.

2. Create a paste from the CLI (curl)

For terminal-driven workflows — error logs, build outputs, sharing a snippet in chat — use the HTTP API:

curl -X POST https://paste.yourdomain.com/api/paste \
  -H "Authorization: Bearer $PASTEHERE_TOKEN" \
  -H "Content-Type: text/plain" \
  --data-binary @error.log

The response JSON contains the url, slug, and expiresAt. Pipe it through jq -r .url and onto your clipboard with pbcopy.

3. Set up an API token

Without authentication, anyone who finds your instance can create pastes. Enable token auth:

  1. Set AUTH_MODE = "token" in wrangler.toml under [vars].
  2. Generate a secret: openssl rand -hex 32.
  3. Store it as a Worker secret: npx wrangler secret put PASTEHERE_TOKEN and paste the value.
  4. Redeploy (npm run deploy). Clients must now send Authorization: Bearer <token> on POST/PUT/DELETE.

4. Run locally with Wrangler dev

Before redeploying, test changes locally with the same bindings as production:

npm run dev

This starts Wrangler's local runtime with a local D1 (Miniflare SQLite) and a local R2 simulator at http://localhost:8787. Changes to the Worker code hot-reload instantly.

Configuration

All runtime configuration lives in wrangler.toml under [vars]. Secrets (tokens, signing keys) go through wrangler secret put and never appear in the file.

wrangler.toml vars

VariableDefaultPurpose
AUTH_MODE"none"none, token (single shared token), or password (HTTP Basic prompt)
DEFAULT_EXPIRY"1d"Expiration applied when the client doesn't send one
MAX_BODY_BYTES524288Hard cap on paste body size — 512 KiB. Raise on Workers Paid for up to 10 MiB per request
SLUG_LENGTH8Random slug length in characters from the Crockford base-32 alphabet
ENABLE_BURN_AFTER_READ"true"Allow clients to mark a paste as one-shot (deletes on first GET)
PUBLIC_LISTING"false"If true, / shows recent pastes — leave false for private deployments
SITE_TITLE"PasteHere"Title shown in the header and <title>

Worker secrets

SecretRequired when
PASTEHERE_TOKENAUTH_MODE = "token"
PASTEHERE_PASSWORDAUTH_MODE = "password"
DELETE_KEYAlways — used to sign delete URLs so they cannot be guessed

Bindings

The Worker binds to one D1 database and one R2 bucket. Both are declared in wrangler.toml:

[[d1_databases]]
binding = "DB"
database_name = "pastehere"
database_id = "<generated>"

[[r2_buckets]]
binding = "BUCKET"
bucket_name = "pastehere-bodies"

Why R2 + D1? D1 holds lightweight metadata (slug, language, expiry, MIME, content hash) for fast listing and search. R2 holds the paste bodies — there is no per-byte egress fee on R2, so a popular paste can be read a million times without charging you anything beyond the storage cost.

Expiry schedule

A scheduled Worker (Cron Trigger) sweeps expired pastes once an hour. Configure it under [triggers] in wrangler.toml:

[triggers]
crons = ["0 * * * *"]

The sweep deletes the D1 row and the R2 object in the same run, then logs a per-object count. Burn-after-read pastes delete on first GET and don't wait for the sweep.

Key Features

Zero-operations self-hosting

No server, no Docker, no Kubernetes, no database administration. Deploy is one command (npm run deploy); Cloudflare handles TLS, scaling, DDoS mitigation, and edge caching. Total maintenance cost: bumping Wrangler when a new minor ships.

Cloudflare R2 storage with zero egress fees

Paste bodies live in R2, not D1 — meaning reads don't count against the D1 row-read quota and don't incur the per-GB egress fees that S3 and GCS charge. A hot paste that's read a million times costs the same as one read once.

D1 metadata for fast search and listing

Every paste's metadata (slug, language, size, expiry, hash, created/updated timestamps) lives in D1 — a distributed SQLite database that runs at the edge. Listing recent pastes and filtering by language is a sub-10 ms indexed query, not an R2 LIST call.

Burn-after-read and expirations

One-shot pastes (popular for sharing secrets) delete themselves on first GET. Combined with explicit expiration (10 min to Never), PasteHere handles both ephemeral chat snippets and long-lived reference material without manual cleanup.

Token or password authentication

Single shared bearer token for API/CLI workflows, or HTTP Basic password prompt for browser-only users. Both modes hide behind the same check; mixing them is supported (token wins on API routes, password protects the web UI).

Syntax-highlighted editor and raw endpoints

The web UI uses a CodeMirror editor with auto-language detection. The same paste is also retrievable raw (/<slug>/raw) and as downloadable text (/<slug>/download?filename=snippet.go) for piping into curl | sh or sharing with CI pipelines.

Troubleshooting

Deploy fails: "D1 database not found"

Symptom: npm run deploy aborts with D1_ERROR: no such database.
Fix: The database_id in wrangler.toml doesn't match an existing D1 instance. Re-run npm run db:init — it prints the correct database_id and writes it into the file. If you manually provisioned, run npx wrangler d1 list and copy the ID verbatim.

R2 binding works locally but errors in production

Symptom: npm run dev uploads and reads pastes; production returns R2 bucket not found on POST.
Fix: The bucket name in wrangler.toml has a typo or belongs to a different Cloudflare account. Buckets are account-scoped — confirm with npx wrangler r2 bucket list that the name matches exactly, then redeploy. Buckets created in a different account must be migrated or re-created in the Worker's account.

POST returns 401 even with correct token

Symptom: API returns {"error":"unauthorized"} with a valid-looking Authorization: Bearer … header.
Fix: The PASTEHERE_TOKEN secret was set in .dev.vars but never pushed to production. Secrets are environment-specific — run npx wrangler secret put PASTEHERE_TOKEN against production explicitly. Verify with npx wrangler secret list.

Expired pastes aren't being deleted

Symptom: Pastes past their expires_at are still retrievable days later.
Fix: The Cron Trigger isn't firing. Check the Cloudflare dashboard → Worker → Triggers → Cron Triggers — verify the schedule is registered. If missing, the [triggers] block in wrangler.toml wasn't applied; redeploy. Also verify the expire handler is exported from src/index.ts — Cron dispatches via scheduled(event, env, ctx).

Body larger than expected rejected

Symptom: Upload of a 1 MB log fails with 413 Payload Too Large.
Fix: The Workers free plan caps request body at 100 KiB; the paid plan ($5/mo) raises it to 10 MiB. Either upgrade or lower MAX_BODY_BYTES to enforce the limit explicitly with a friendlier error message. Bodies over 10 MiB can't go through Workers — direct R2 upload via presigned URL is the workaround.

D1 row reads quota exhausted

Symptom: High-traffic listing pages return D1_ERROR: quota exceeded.
Fix: The free D1 plan caps at 5M row reads/day; paid caps at 25B then bills per-million. Two fixes: (1) Cache the public listing response at the edge with Cache-Control: s-maxage=60 — most visitors never hit D1. (2) Move the public listing off D1 entirely and pre-generate it into an R2 object that the cron updates hourly.

Support

When reporting a deployment issue, include the redacted wrangler.toml, the output of npx wrangler --version, your Cloudflare plan tier (Free / Workers Paid), and the relevant excerpt from npx wrangler tail logs.