Skip to content

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) -- Provides envelope.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 both apps/api and apps/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 using commander. Supports E2EE by encrypting locally before sending ciphertext to the server.

Tech Stack

LayerTechnology
HTTP frameworkFastify 5
ORMDrizzle ORM (postgres-js driver)
ValidationZod 3
TemplatingEJS (via @fastify/view)
Encryptionlibsodium-wrappers (XChaCha20-Poly1305)
CLI frameworkCommander 12
LoggingPino (with pino-pretty for dev)
Syntax highlightinghighlight.js
Markdownmarkdown-it
DatabasePostgreSQL 17
Process managerPM2
Reverse proxyNginx

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.

  1. A random 256-bit Data Encryption Key (DEK) is generated via crypto_aead_xchacha20poly1305_ietf_keygen().
  2. Paste content is encrypted with the DEK using XChaCha20-Poly1305 (192-bit random nonce).
  3. The DEK itself is wrapped (encrypted) with the MASTER_KEY using the same cipher.
  4. The database stores encrypted_content (base64url of nonce || ciphertext) and encrypted_dek (base64url of nonce || wrapped DEK).
  5. 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.

  1. The client generates a random 256-bit key and encrypts content locally using XChaCha20-Poly1305.
  2. The client sends the ciphertext to the server. The server stores it as-is with is_e2ee = true and encrypted_dek = NULL.
  3. The decryption key is placed in the URL fragment (#<base64url-key>), which is never sent to the server.
  4. 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:

TablePurpose
usersUser accounts (username, email, is_active)
api_keysAPI keys linked to users. Stores SHA-256 hash of the key, a display prefix, and last-used timestamp
pastesCore 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_versionsVersion history for paste edits. Stores version number and encrypted content/DEK per version
webhooksUser-registered webhook endpoints (URL, shared secret, subscribed event types)
webhook_deliveriesDelivery 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_tokensOne-time tokens for linking Telegram chats (expires in 15 min)
telegram_destinationsLinked Telegram chat destinations per user
notification_queueQueued Telegram notifications. Same retry/status model as webhook_deliveries
audit_eventsAudit log (action, actor type/id, resource type/id, metadata, IP). Indexed on action and created_at
plansPlan definitions (max pastes, max paste size, max TTL, feature flags)
user_planMaps 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:

WorkerClassDefault IntervalBehavior
Webhook deliveryWebhookDeliveryWorker5 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 notificationsTelegramWorker5 sProcesses pending notifications from notification_queue. Sends messages via Telegram Bot API. Same retry/backoff logic as webhooks
TTL cleanupTtlCleanupWorker60 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.

ServiceResponsibilities
EncryptionServiceMode A encrypt/decrypt using MASTER_KEY
PasteServiceCreate, read, list, delete, extend TTL, delete expired pastes
UserServiceUser CRUD, API key generation/validation
AuditServiceWrite and query audit log entries
WebhookServiceWebhook CRUD, delivery enqueueing, test sends
TelegramServiceToken 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 -> PostgreSQL

For paste creation:

  1. Request validated with Zod (createPasteSchema)
  2. PasteService.create() generates a public ID, encrypts content (Mode A or stores pre-encrypted Mode B content)
  3. Row inserted into pastes table
  4. Audit event logged
  5. Webhook deliveries enqueued for subscribed hooks
  6. Response returned with public ID and URL

Self-hosted paste service with E2EE