Self-hosted, end-to-end encrypted paste service.
The server stores ciphertext only — it can never read your content.
A modern, privacy-first alternative to Pastebin and PrivateBin.
Documentation · Demo · CLI on PyPI · Self-hosting
A paste after client-side decryption — the server only ever stored ciphertext.
![]() | ![]() |
| Create a paste — dark | Create a paste — light |
Ghostbit encrypts your content in the browser using the Web Crypto API before sending anything to the server. The decryption key lives exclusively in the URL fragment — it is never transmitted over the network.
https://paste.example.com/aB3kZx9m#KEY~DELETE_TOKEN
↑
never sent to the server
| Paste type | Key source | Where the key lives |
|---|---|---|
| No password | crypto.subtle.generateKey() | URL #fragment |
| With password | PBKDF2-SHA256 (600k iter) | User's memory |
- True E2E encryption — AES-256-GCM, server sees ciphertext only
- Burn after read — deleted permanently after the first view
- Max views — auto-deleted after N reads
- Expiration — from 5 minutes to 1 year
- Password protection — client-side key derivation, password never leaves the browser
- Webhook — POST notification on each read
- Language detection — auto-detected from content or file extension
- Markdown preview — rendered in-browser
- CLI —
gbitcommand, pipe anything from your terminal - REST API — full API for automation and integrations
- SQLite / Redis — swap storage backends with a single env var
pip install ghostbit-cli# Paste from stdin
cat main.py | gbit
# Paste a file (language auto-detected)
gbit secrets.env --burn --expires 3600
# Password-protected (secure prompt)echo"db_pass=s3cr3t"| gbit -p
# Scripting
URL=$(cat deploy.sh | gbit --quiet)# Point to your instance
gbit config set server https://paste.example.com
# Shell completion (bash / zsh / fish)eval"$(gbit completion bash)"eval"$(gbit completion zsh)"
gbit completion fish |sourcedocker pull stackopshq/ghostbit # Docker Hub
docker pull ghcr.io/stackopshq/ghostbit # GHCRgit clone https://github.com/stackopshq/ghostbit
cd ghostbit
cp .env.example .env
docker compose up -dSTORAGE_BACKEND=redis docker compose --profile redis up -d
# With a password
STORAGE_BACKEND=redis REDIS_PASSWORD=mysecret docker compose --profile redis up -dCreate /etc/containers/systemd/ghostbit.container (system-wide) or ~/.config/containers/systemd/ghostbit.container (rootless):
[Unit]Description=Ghostbit paste service
After=network-online.target
[Container]Image=ghcr.io/stackopshq/ghostbit:latest
PublishPort=8000:8000
Volume=ghostbit_data:/data
Environment=STORAGE_BACKEND=sqlite
Environment=SQLITE_PATH=/data/ghostbit.db
Environment=MAX_PASTE_SIZE=524288
Environment=PORT=8000
HealthCmd=wget -qO- http://127.0.0.1:8000/healthz || exit 1
HealthInterval=30s
HealthTimeout=5s
HealthStartPeriod=15s
HealthRetries=3
[Service]Restart=always
[Install]WantedBy=default.target# Reload systemd and start
systemctl --user daemon-reload
systemctl --user enable --now ghostbitFor Redis, add a ghostbit-redis.container alongside and use After=ghostbit-redis.service + Environment=STORAGE_BACKEND=redis + Environment=REDIS_URL=redis://ghostbit-redis:6379. Podman Quadlet handles the pod networking automatically.
scripts/backup.sh streams python -m app.admin export through age and writes one timestamped .jsonl.age file per run. The plaintext export never touches disk — a stolen backup file is useless without the age private key.
One-shot:
BACKUP_DIR=/var/backups/ghostbit \
AGE_RECIPIENT="age1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
scripts/backup.shRecurring via systemd — copy the two unit templates and adjust paths + recipient:
sudo cp scripts/ghostbit-backup.service /etc/systemd/system/
sudo cp scripts/ghostbit-backup.timer /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now ghostbit-backup.timer
sudo systemctl list-timers ghostbit-backup.timerRestore:
age --decrypt -i ~/.config/age/keys.txt ghostbit-2026-05-24T03-17-00Z.jsonl.age \
| python -m app.admin importapp.admin import --overwrite replaces existing IDs; without it, conflicts are skipped (safe re-runs).
| Variable | Default | Description |
|---|---|---|
STORAGE_BACKEND | sqlite | sqlite or redis |
SQLITE_PATH | ./ghostbit.db | SQLite file path (Docker overrides to /data/ghostbit.db) |
SQLITE_POOL_SIZE | 5 | Pooled SQLite connections (WAL enables parallel readers) |
REDIS_URL | redis://localhost:6379 | Redis connection URL |
REDIS_PASSWORD | — | Redis password (injected into REDIS_URL automatically) |
MAX_PASTE_SIZE | 524288 | Max paste size in bytes (512 KB) |
PORT | 8000 | Server port |
RATE_LIMIT_CREATE | 30/minute | Rate limit for paste creation |
RATE_LIMIT_VIEW | 120/minute | Rate limit for paste viewing |
TRUST_PROXY_HEADERS | false | Use rightmost X-Forwarded-For for rate limiting (enable only behind a trusted proxy) |
BASE_URL | — | Public base URL (e.g. https://paste.example.com) for the absolute links in social-preview meta tags. Derived from the request when unset. |
WEBHOOK_SECRET | — | HMAC-SHA256 secret for signing webhook payloads |
All content is encrypted client-side — the API only handles ciphertext. Interactive docs are available at /docs (Swagger UI) and /redoc (ReDoc). Operators also get /healthz (liveness — always 200 if the process is alive), /readyz (readiness — 503 if the storage backend doesn't answer) and /metrics (Prometheus exposition).
# Create (content must be pre-encrypted — use the CLI or e2e.js)
curl -X POST https://paste.example.com/api/v1/pastes \
-H "Content-Type: application/json" \
-d '{"content":"<base64 ciphertext>","nonce":"<base64 nonce>","language":"python"}'# Retrieve (returns ciphertext — client decrypts)
curl https://paste.example.com/api/v1/pastes/{id}
# Delete
curl -X DELETE https://paste.example.com/api/v1/pastes/{id} \
-H "X-Delete-Token: <token>"# Detect language (plaintext)
curl -X POST https://paste.example.com/api/v1/detect \
-H "Content-Type: application/json" \
-d '{"content":"def hello():\n print(42)"}'Interactive Swagger UI: /docs — ReDoc: /redoc.
git clone https://github.com/stackopshq/ghostbit
cd ghostbit
python3 -m venv .venv &&source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env
uvicorn app.main:app --reload --port 8000Open http://localhost:8000. SQLite is used by default, no external service required.
pip install -r requirements-dev.txt
pytest tests/ -vInstall once per clone to run ruff, gitleaks and a few hygiene checks on every commit:
pip install pre-commit
pre-commit installThe same checks run in CI — installing the hook just gives you the feedback locally before the push.
Ghostbit follows a zero-knowledge architecture:
| Server sees | Server cannot see | |
|---|---|---|
| Paste content | AES-256-GCM ciphertext | Plaintext |
| Encryption key | Never (stays in URL #fragment) | — |
| Password | Never (PBKDF2 runs in browser/CLI) | — |
| Delete token | SHA-256 hash only | Plaintext token |
| Metadata | Language, timestamps, view count | — |
- The URL
#fragmentis never sent to the server by any browser. - A compromised server cannot decrypt any paste — past or future.
- SSRF protection blocks webhooks to private/internal networks.
- Rate limiting protects against abuse on all endpoints.
If you discover a security vulnerability, please report it responsibly via GitHub Security Advisories.
- Fork the repository
- Create a feature branch (
git checkout -b feat/my-feature) - Commit with clear messages (
git commit -m "feat: add X") - Push and open a Pull Request
Please ensure:
- All existing tests pass (
pytest tests/ -v) - New features include tests when applicable
- Code follows the existing style (no linter enforced, just be consistent)
MIT



