Skip to content

Latest commit

 

History

History
277 lines (202 loc) · 10.2 KB

SELF-HOSTING.md

File metadata and controls

277 lines (202 loc) · 10.2 KB

Self-Hosting Guide

Deploy your own instance of Bin 21 on your infrastructure.

Prerequisites

  • Node.js 20+
  • S3-compatible object storage — stores paste content (Cloudflare R2, AWS S3, MinIO, Backblaze B2, etc.)
  • Persistent disk — for the SQLite file holding paste metadata

No database or cache server is required. Paste metadata lives in a SQLite file; rate limiting is in-memory.

Single instance only. SQLite plus in-memory rate limiting means you cannot run multiple replicas against one database file. If you need horizontal scaling, swap lib/db/index.ts for a networked database and lib/rate-limit.ts for a shared store.

Environment Variables

Copy .env.example to .env and configure:

Database (optional)

DATABASE_PATH=/data/bin21.db

Path to the SQLite file. Defaults to ./.data/bin21.db. Point this at a persistent location — a container's ephemeral filesystem will lose every paste on restart. The file and its schema are created automatically on first boot (migrations run from lib/db/index.ts).

Back this file up. It is the only copy of your paste metadata. Because SQLite runs in WAL mode, copy it with sqlite3 bin21.db ".backup out.db" rather than cp, which can capture a torn write.

Object Storage (required)

R2_ACCOUNT_ID=your-account-id
R2_ACCESS_KEY_ID=your-access-key
R2_SECRET_ACCESS_KEY=your-secret-key
R2_BUCKET_NAME=bin21-pastes
R2_PUBLIC_URL=https://your-public-url.com

Any S3-compatible storage works. The R2_ACCOUNT_ID constructs the endpoint as https://{R2_ACCOUNT_ID}.r2.cloudflarestorage.com. For other providers (AWS S3, MinIO, Backblaze B2), modify the endpoint in server/services/storage.service.ts.

Application (required)

NEXT_PUBLIC_APP_URL=https://paste.example.com

The public URL where your instance is accessible. Used for paste links and OpenGraph metadata.

Security (recommended)

IP_HASH_SALT=generate-with-openssl-rand-hex-32
BOT_PROTECTION_ENABLED=true

Generate a salt with openssl rand -hex 32. Bot protection enables honeypot fields and time-based detection.

Expired-paste janitor (recommended)

JANITOR_ENABLED=true

Expired pastes always read as "not found", but something has to reclaim them. This starts an in-process sweep every 15 minutes that deletes expired rows and their objects from your bucket.

Set it only on the deployed instance. A development machine configured against your production bucket will happily reclaim live storage.

Admin analytics (optional)

ADMIN_TOKEN=generate-with-openssl-rand-hex-32

Enables a dashboard at /admin with paste/view totals, language mix, size distribution, and activity over time. Leave it unset and the route returns a genuine 404 — the page does not exist. Authentication is a single token compared with a fixed-length digest, stored in an httpOnly cookie; login attempts are throttled to 5 per 5 minutes per IP.

Deployment Options

Option 1: Docker

Create a Dockerfile:

FROM node:20-alpine AS base
WORKDIR /app
# better-sqlite3 is a native addon; alpine needs a toolchain to build it if no
# prebuilt binary matches the platform.
RUN apk add --no-cache python3 make g++ libc6-compat

FROM base AS deps
COPY package*.json ./
RUN npm ci

FROM base AS builder
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

FROM base AS runner
ENV NODE_ENV=production
# .output is self-contained: the server bundle, the node_modules it needs
# (traced, including the native better-sqlite3 binary), and public assets.
COPY --from=builder /app/.output ./.output
COPY --from=builder /app/lib/db/migrations ./lib/db/migrations
EXPOSE 3000
CMD ["node", "--max-old-space-size=256", ".output/server/index.mjs"]

Two things to know about this image:

  • lib/db/migrations must be copied: migrations are read from disk at boot. Without them the app cannot create its schema.
  • .output is all you need from the build. Nitro traces the server's dependencies into .output/server/node_modules. Shiki, better-sqlite3 and @vercel/og are kept as real packages there (see traceDeps in vite.config.ts), because Shiki loads grammars on demand and better-sqlite3 is a native addon. Build in the same image/platform you run in.
  • --max-old-space-size=256 caps the V8 heap so garbage collection runs early instead of letting memory drift toward the container limit.

Build and run:

docker build -t bin21 .
docker run -p 3000:3000 -v bin21data:/data --env-file .env bin21

The -v is not optional. Without a volume the SQLite file lives in the container's writable layer and every paste is lost on restart.

Option 2: Docker Compose

A single service plus a volume for the SQLite file:

services:
  app:
    build: .
    ports:
      - "3000:3000"
    environment:
      DATABASE_PATH: /data/bin21.db
      NEXT_PUBLIC_APP_URL: https://paste.example.com
      R2_ACCOUNT_ID: your-account-id
      R2_ACCESS_KEY_ID: your-access-key
      R2_SECRET_ACCESS_KEY: your-secret-key
      R2_BUCKET_NAME: bin21-pastes
      IP_HASH_SALT: change-me
      BOT_PROTECTION_ENABLED: "true"
      JANITOR_ENABLED: "true"
    volumes:
      - bin21data:/data

volumes:
  bin21data:

Start the stack:

docker compose up -d

No schema step is needed — migrations run on boot.

Do not scale this service (docker compose up --scale app=2). Multiple instances writing one SQLite file over a shared volume will corrupt it, and each would keep its own rate-limit counters.

Option 3: Manual (Node.js)

git clone https://github.com/t21dev/bin-21.git
cd bin-21
npm install
cp .env.example .env
# Edit .env with your configuration

npm run build      # Build for production (outputs .output/)
npm start          # Start on port 3000 (migrations run on boot; set PORT to change)

Use pm2 to keep the app running:

npm install -g pm2
pm2 start npm --name bin21 -- start
pm2 save
pm2 startup

Reverse Proxy

Put Bin 21 behind a reverse proxy for SSL termination. Minimal nginx config:

server {
    listen 80;
    server_name paste.example.com;
    return 301 https://$server_name$request_uri;
}

server {
    listen 443 ssl http2;
    server_name paste.example.com;

    ssl_certificate /etc/letsencrypt/live/paste.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/paste.example.com/privkey.pem;

    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_cache_bypass $http_upgrade;
    }
}

The X-Forwarded-For and X-Real-IP headers are important — the rate limiter uses them to identify clients.

Using MinIO Instead of Cloudflare R2

For fully self-contained storage, use MinIO as a drop-in S3-compatible replacement.

Add to your Docker Compose:

  minio:
    image: minio/minio
    command: server /data --console-address ":9001"
    environment:
      MINIO_ROOT_USER: minioadmin
      MINIO_ROOT_PASSWORD: minioadmin
    ports:
      - "9000:9000"
      - "9001:9001"
    volumes:
      - miniodata:/data

Then modify the S3 client in server/services/storage.service.ts to point to your MinIO instance and set forcePathStyle: true in the S3 client config.

Database Management

Migrations in lib/db/migrations are applied automatically on boot, so a deploy needs no schema step.

  • npm run db:generate — generate a SQL migration after editing lib/db/schema.ts (commit the result)
  • npm run db:push — push the schema directly without a migration file (dev shortcut only)
  • npm run db:studio — visual database browser for inspecting data

Backups

The SQLite file is the only copy of your paste metadata. Back it up with SQLite's own API rather than a file copy — WAL mode means cp can capture a torn write:

sqlite3 /data/bin21.db ".backup /backups/bin21-$(date +%F).db"

On Railway, enable automated volume backups.

Updating

git pull origin main
npm install
npm run build
# Restart your server / container (migrations apply on boot)

Troubleshooting

  • Shiki or better-sqlite3 errors at runtime — check that .output/server/node_modules contains shiki, @shikijs and better-sqlite3. They are traced there by traceDeps in vite.config.ts; do not remove those entries.
  • DATABASE_PATH ... is a relative path on boot — in production the path must be absolute (e.g. /data/bin21.db) so data lands on the mounted volume.
  • R2/S3 connection errors — verify your access key, secret key, and bucket name. Ensure the bucket exists and credentials have read/write permissions.
  • All pastes disappear on restart — DATABASE_PATH is pointing at ephemeral storage. Mount a volume and point it there.
  • no such table: pastes — the lib/db/migrations directory is missing from the deployed image. Migrations are read from disk at boot; copy that folder.
  • SQLITE_CANTOPEN — the process cannot write to the directory holding DATABASE_PATH. Check the mount exists and is writable by the container user.
  • SQLITE_BUSY / database is locked — more than one process is writing the file. Run exactly one instance; do not scale the service.
  • invalid ELF header / NODE_MODULE_VERSION mismatch on boot — better-sqlite3 is a native addon built for a specific platform and Node major version. Rebuild inside the target image (npm rebuild better-sqlite3) rather than copying node_modules across platforms.
  • Rate limiter not working — if behind a reverse proxy, ensure X-Forwarded-For or X-Real-IP headers are passed. The rate limiter falls back to 127.0.0.1 when no client IP is detected.
  • /admin returns 404 — ADMIN_TOKEN is unset. That is the intended behaviour when it's not configured.
  • Expired pastes never disappear from storage — JANITOR_ENABLED is not true. They already read as "not found"; the janitor is what reclaims the row and the object.