Architecture
System Overview
Pastebox is a self-hosted encrypted pastebin built as a pnpm monorepo with three packages:
pastebox/
packages/
shared/ # Crypto primitives, Zod schemas, shared types
apps/
api/ # Fastify HTTP server + background workers
cli/ # Commander-based CLI client
infra/ # Docker Compose, Ansible, Nginx, PM2, restic, certbot- packages/shared (
@pastebox/shared) -- Providesenvelope.ts(Mode A encryption),e2ee.ts(Mode B encryption),public-id.ts(random ID generation),hmac.ts(webhook signing), and Zod schemas for paste input validation. Has no server or CLI dependencies; consumed by bothapps/apiandapps/cli. - apps/api (
@pastebox/api) -- Fastify 5 HTTP server exposing REST API (/api/v1/*), server-rendered HTML views (/p/:publicId), an admin dashboard (/admin/*), and health endpoints (/healthz,/readyz). Also provides a separate worker entrypoint (worker.ts) that runs three background workers in a single process. - apps/cli (
@pastebox/cli) -- Thin CLI that wraps the REST API usingcommander. Supports E2EE by encrypting locally before sending ciphertext to the server.
Tech Stack
| Layer | Technology |
|---|---|
| HTTP framework | Fastify 5 |
| ORM | Drizzle ORM (postgres-js driver) |
| Validation | Zod 3 |
| Templating | EJS (via @fastify/view) |
| Encryption | libsodium-wrappers (XChaCha20-Poly1305) |
| CLI framework | Commander 12 |
| Logging | Pino (with pino-pretty for dev) |
| Syntax highlighting | highlight.js |
| Markdown | markdown-it |
| Database | PostgreSQL 17 |
| Process manager | PM2 |
| Reverse proxy | Nginx |
Encryption Model
Pastebox supports two encryption modes. All pastes are encrypted at rest -- there is no plaintext path.
Mode A -- Envelope Encryption (Server-Side)
Used by default when a paste is created without the --e2ee flag.
- A random 256-bit Data Encryption Key (DEK) is generated via
crypto_aead_xchacha20poly1305_ietf_keygen(). - Paste content is encrypted with the DEK using XChaCha20-Poly1305 (192-bit random nonce).
- The DEK itself is wrapped (encrypted) with the
MASTER_KEYusing the same cipher. - The database stores
encrypted_content(base64url of nonce || ciphertext) andencrypted_dek(base64url of nonce || wrapped DEK). - On read, the server unwraps the DEK with
MASTER_KEY, then decrypts the content.
The MASTER_KEY is a 32-byte secret provided via the MASTER_KEY environment variable (base64url-encoded). Rotating it requires re-encrypting all stored DEKs.
Mode B -- End-to-End Encryption (Client-Side)
Used when the --e2ee flag is set or isE2ee: true is passed in the API request.
- The client generates a random 256-bit key and encrypts content locally using XChaCha20-Poly1305.
- The client sends the ciphertext to the server. The server stores it as-is with
is_e2ee = trueandencrypted_dek = NULL. - The decryption key is placed in the URL fragment (
#<base64url-key>), which is never sent to the server. - On read, the server returns the raw ciphertext. The browser or CLI decrypts locally using the key from the URL fragment.
The server never sees the plaintext or the decryption key in Mode B.
Database Schema
PostgreSQL with 12 tables managed by Drizzle ORM:
| Table | Purpose |
|---|---|
users | User accounts (username, email, is_active) |
api_keys | API keys linked to users. Stores SHA-256 hash of the key, a display prefix, and last-used timestamp |
pastes | Core paste storage. Includes public_id, encrypted_content, encrypted_dek, is_e2ee, burn_after_read, expires_at, view_count. Indexed on public_id, expires_at, user_id |
paste_versions | Version history for paste edits. Stores version number and encrypted content/DEK per version |
webhooks | User-registered webhook endpoints (URL, shared secret, subscribed event types) |
webhook_deliveries | Delivery queue for webhook events. Tracks status (pending/retrying/delivered/failed/cancelled), attempts, next_retry_at, response details. Indexed on status and next_retry_at |
telegram_tokens | One-time tokens for linking Telegram chats (expires in 15 min) |
telegram_destinations | Linked Telegram chat destinations per user |
notification_queue | Queued Telegram notifications. Same retry/status model as webhook_deliveries |
audit_events | Audit log (action, actor type/id, resource type/id, metadata, IP). Indexed on action and created_at |
plans | Plan definitions (max pastes, max paste size, max TTL, feature flags) |
user_plan | Maps a user to a plan (one plan per user) |
Worker Architecture
Background tasks run in a separate process (apps/api/src/worker.ts) managed by PM2 as a single fork-mode instance. The process instantiates three worker classes that each run independent polling loops:
| Worker | Class | Default Interval | Behavior |
|---|---|---|---|
| Webhook delivery | WebhookDeliveryWorker | 5 s (WORKER_POLL_INTERVAL_MS) | Picks up to 10 pending/retrying deliveries per tick. Signs payload with HMAC-SHA256. On failure, retries with exponential backoff (2^attempts * 10s). Gives up after WORKER_WEBHOOK_MAX_RETRIES (default 5) attempts |
| Telegram notifications | TelegramWorker | 5 s | Processes pending notifications from notification_queue. Sends messages via Telegram Bot API. Same retry/backoff logic as webhooks |
| TTL cleanup | TtlCleanupWorker | 60 s (WORKER_TTL_CLEANUP_INTERVAL_MS) | Deletes pastes where expires_at < now(). Also cleans up expired unused Telegram linking tokens |
All three workers use setTimeout-based polling (not setInterval) to prevent overlapping ticks. The process listens for SIGINT/SIGTERM to stop all workers and close the database connection cleanly.
Service Layer
Business logic lives in plain TypeScript classes under apps/api/src/services/. Dependencies are injected through constructor parameters (database, other services, logger). Services are instantiated once by the servicesPlugin and attached to the Fastify instance as fastify.services.
| Service | Responsibilities |
|---|---|
EncryptionService | Mode A encrypt/decrypt using MASTER_KEY |
PasteService | Create, read, list, delete, extend TTL, delete expired pastes |
UserService | User CRUD, API key generation/validation |
AuditService | Write and query audit log entries |
WebhookService | Webhook CRUD, delivery enqueueing, test sends |
TelegramService | Token generation, chat linking, notification enqueueing, Bot API messaging, token cleanup |
Admin Dashboard
The admin panel is served at /admin with server-rendered EJS templates. It has no application-level authentication -- access control is handled entirely by Nginx Basic Auth (auth_basic_user_file /etc/nginx/htpasswd/pastebox).
Admin pages:
/admin-- Dashboard with aggregate stats (total pastes, users, webhooks, pending deliveries)/admin/pastes-- List and delete pastes/admin/webhooks-- View registered webhooks/admin/telegram-- View linked Telegram destinations/admin/audit-- Browse audit log
Request Flow
Client -> Nginx (TLS, security headers, Basic Auth for /admin)
-> Fastify (rate limit, optional Turnstile, API key auth)
-> Route handler -> Service -> Drizzle ORM -> PostgreSQLFor paste creation:
- Request validated with Zod (
createPasteSchema) PasteService.create()generates a public ID, encrypts content (Mode A or stores pre-encrypted Mode B content)- Row inserted into
pastestable - Audit event logged
- Webhook deliveries enqueued for subscribed hooks
- Response returned with public ID and URL