Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<id>` 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

Expand Down
6 changes: 3 additions & 3 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
18 changes: 18 additions & 0 deletions deploy/Caddyfile
Original file line number Diff line number Diff line change
@@ -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}
}
}
70 changes: 69 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
@@ -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:
25 changes: 23 additions & 2 deletions docs/deploy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading