From 926de63c58afbb08e2256899f5b96bccd84cac54 Mon Sep 17 00:00:00 2001 From: ArianAr Date: Thu, 16 Jul 2026 01:10:30 +0300 Subject: [PATCH] feat(docker): Caddy proxy and expire sweeper compose profiles Add profile `proxy` (Caddy reverse proxy + ACME) and profile `sweeper` (curl loop against /api/health to purge expired pastes). Document usage in docs/deploy. Closes #38 Closes # --- CHANGELOG.md | 1 + ROADMAP.md | 6 ++-- deploy/Caddyfile | 18 +++++++++++ docker-compose.yml | 70 ++++++++++++++++++++++++++++++++++++++++++- docs/deploy/README.md | 25 ++++++++++++++-- 5 files changed, 114 insertions(+), 6 deletions(-) create mode 100644 deploy/Caddyfile diff --git a/CHANGELOG.md b/CHANGELOG.md index a253bfc..083e8f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Log canvas **timeline scrubber** for timestamped logs (ISO / date-time / syslog / epoch) - Log canvas **multi-paste compare** (side-by-side via `?compare=` or sidebar) - Proxy-aware runtime: `TRUSTED_PROXY_HOPS` for rate-limit client keys; Secure cookies from `PAPERCUT_PUBLIC_URL` / `COOKIE_SECURE` +- Docker Compose **profiles**: `proxy` (Caddy + ACME), `sweeper` (periodic `/api/health` purge) ### Fixed diff --git a/ROADMAP.md b/ROADMAP.md index e45cfde..e452b23 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -94,9 +94,9 @@ Focus: multi-instance self-host without losing privacy defaults. | 1.3.1 | **Shared rate-limit store** (Redis optional) | server, docker | Fallback: in-memory | | 1.3.2 | **Object storage for large pastes** (S3/MinIO optional) | server | Keep SQLite for metadata | | 1.3.3 | **Streaming upload** (chunked stdin) | cli, api | Very large builds | -| 1.3.4 | **Background expire sweeper** (cron process) | server | Complement purge-on-read | -| 1.3.5 | **Read replicas / RO mode** | server | Optional | -| 1.3.6 | **Optional reverse-proxy stack** (compose profiles) | docker | nginx / Caddy / Traefik + ACME for domain + HTTPS; app still plain HTTP behind proxy | +| 1.3.4 | **Background expire sweeper** (cron process) | server | ✅ Done (compose profile `sweeper` → `/api/health`) | +| 1.3.5 | **Read replicas / RO mode** | server | Optional — deferred (complexity vs single-node default) | +| 1.3.6 | **Optional reverse-proxy stack** (compose profiles) | docker | ✅ Done (Caddy profile `proxy` + ACME) | | 1.3.7 | **Proxy-aware runtime** | server | ✅ Done (`TRUSTED_PROXY_HOPS`, `COOKIE_SECURE` / public URL) | ### Reverse proxy & HTTPS (intent) diff --git a/deploy/Caddyfile b/deploy/Caddyfile new file mode 100644 index 0000000..5caf7a1 --- /dev/null +++ b/deploy/Caddyfile @@ -0,0 +1,18 @@ +# PaperCut reverse proxy (compose profile `proxy`) +# Set PAPERCUT_DOMAIN and ensure DNS points here for ACME HTTPS. +# Local smoke test without HTTPS: use :80 only and papercut.localhost + +{ + # Uncomment for local/dev without real DNS: + # auto_https off +} + +{$PAPERCUT_DOMAIN:localhost} { + encode gzip + + reverse_proxy papercut:3000 { + header_up X-Forwarded-Proto {scheme} + header_up X-Forwarded-For {remote_host} + header_up Host {host} + } +} diff --git a/docker-compose.yml b/docker-compose.yml index 3d839ef..19a9351 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,17 +1,85 @@ +# PaperCut Docker Compose +# +# Default (app only, host :3000): +# docker compose up --build -d +# +# Behind Caddy with automatic HTTPS (profile `proxy`): +# export PAPERCUT_DOMAIN=paste.example.com +# export PAPERCUT_PUBLIC_URL=https://paste.example.com +# export PASTE_AUTH_SECRET="$(openssl rand -hex 32)" +# docker compose --profile proxy up --build -d +# # Prefer firewall: only 80/443 public; optional PAPERCUT_HOST_PORT= to avoid publishing 3000 +# +# Optional expire sweeper (hits /api/health every 5m — purges expired pastes): +# docker compose --profile sweeper up -d + services: papercut: build: . + # Publish to host only when PAPERCUT_HOST_PORT is set (default 3000 for direct access). + # For proxy-only deploys: PAPERCUT_HOST_PORT= docker compose --profile proxy up -d ports: - - "3000:3000" + - "${PAPERCUT_HOST_PORT:-3000}:3000" environment: PAPERCUT_PUBLIC_URL: ${PAPERCUT_PUBLIC_URL:-http://localhost:3000} DATABASE_PATH: /data/papercut.db PASTE_AUTH_SECRET: ${PASTE_AUTH_SECRET:-change-me-in-production-use-a-long-random-string} MAX_PASTE_SIZE: ${MAX_PASTE_SIZE:-10485760} + TRUSTED_PROXY_HOPS: ${TRUSTED_PROXY_HOPS:-1} NODE_ENV: production volumes: - papercut-data:/data restart: unless-stopped + healthcheck: + test: + [ + "CMD", + "node", + "-e", + "fetch('http://127.0.0.1:3000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))", + ] + interval: 30s + timeout: 5s + retries: 3 + start_period: 20s + + caddy: + profiles: ["proxy"] + image: caddy:2.9-alpine + ports: + - "80:80" + - "443:443" + - "443:443/udp" + environment: + PAPERCUT_DOMAIN: ${PAPERCUT_DOMAIN:-localhost} + volumes: + - ./deploy/Caddyfile:/etc/caddy/Caddyfile:ro + - caddy-data:/data + - caddy-config:/config + depends_on: + papercut: + condition: service_started + restart: unless-stopped + + # Optional: call health periodically so expired pastes are purged without traffic. + sweeper: + profiles: ["sweeper"] + image: curlimages/curl:8.12.1 + entrypoint: + - /bin/sh + - -c + - | + while true; do + curl -sf "http://papercut:3000/api/health" >/dev/null || true + sleep "$${SWEEP_INTERVAL_SEC:-300}" + done + environment: + SWEEP_INTERVAL_SEC: ${SWEEP_INTERVAL_SEC:-300} + depends_on: + - papercut + restart: unless-stopped volumes: papercut-data: + caddy-data: + caddy-config: diff --git a/docs/deploy/README.md b/docs/deploy/README.md index 635d56c..d76eee2 100644 --- a/docs/deploy/README.md +++ b/docs/deploy/README.md @@ -18,16 +18,37 @@ Self-host PaperCut with Docker (or Node), optionally behind a reverse proxy for ```bash export PASTE_AUTH_SECRET="$(openssl rand -hex 32)" -export PAPERCUT_PUBLIC_URL="https://paste.example.com" # public URL users open +export PAPERCUT_PUBLIC_URL="http://localhost:3000" docker compose up --build -d ``` - App listens on **port 3000** (HTTP) inside the container / on the host. - Data: Docker volume `papercut-data` → `/data/papercut.db`. -- Health: `GET /api/health`. +- Health: `GET /api/health` (also purges expired pastes). PaperCut does **not** terminate TLS itself. Put a reverse proxy in front for HTTPS. +### Compose profiles + +| Profile | Command | What | +|---------|---------|------| +| (default) | `docker compose up -d` | App only on host port `PAPERCUT_HOST_PORT` (default `3000`) | +| `proxy` | `docker compose --profile proxy up -d` | + **Caddy** on 80/443 with ACME (set `PAPERCUT_DOMAIN`) | +| `sweeper` | `docker compose --profile sweeper up -d` | Polls `/api/health` every 5m to purge expired pastes without traffic | + +**HTTPS with Caddy (recommended path):** + +```bash +export PASTE_AUTH_SECRET="$(openssl rand -hex 32)" +export PAPERCUT_DOMAIN="paste.example.com" +export PAPERCUT_PUBLIC_URL="https://paste.example.com" +# Optional: do not publish app port on the host (proxy-only) +export PAPERCUT_HOST_PORT="" +docker compose --profile proxy up --build -d +``` + +Caddyfile: [`deploy/Caddyfile`](../../deploy/Caddyfile). DNS for `PAPERCUT_DOMAIN` must point at the host for Let's Encrypt. + --- ## SQLite backup