diff --git a/packages/print/README.md b/packages/print/README.md
new file mode 100644
index 000000000..1a46e28bc
--- /dev/null
+++ b/packages/print/README.md
@@ -0,0 +1,52 @@
+# Omnivore Print-to-PDF (macOS)
+
+This adds a local IPP “virtual printer” (running in Docker) that writes PDFs to a shared spool volume, then uploads each PDF into your Omnivore library as a File/PDF item via the normal GraphQL signed-upload flow.
+
+## Requirements
+
+- Self-hosting stack running via docker compose
+- A dedicated watcher env file:
+ - `self-hosting/docker-compose/print-watcher/.env` (copy from `.env.example`)
+- `print-server` runs on its own LAN IP (macvlan) for Bonjour/mDNS. You must set:
+ - `PRINT_MACVLAN_PARENT` (host interface name, e.g. `eth0`)
+ - `PRINT_SERVER_IP` (an unused LAN IP, e.g. `10.0.1.250`)
+ - Optional: `PRINT_MACVLAN_SUBNET`, `PRINT_MACVLAN_GATEWAY`
+
+## Start services
+
+From `self-hosting/docker-compose/` (canonical) or `self-hosting/docker-compose/self-build/`:
+
+```sh
+COMPOSE_PROFILES=print docker compose up -d --build
+```
+
+This exposes IPP on:
+
+- `PRINT_SERVER_IP:631` (CUPS on the print server container’s LAN IP)
+- Bonjour/mDNS advertisement (should appear automatically in macOS “Add Printer…”)
+
+## Add the printer in macOS
+
+Option A (preferred): use Bonjour discovery
+
+1. System Settings → Printers & Scanners → Add Printer…
+2. Default tab → select `OmnivorePDF` (or similar) when it appears.
+
+Option B: manual IPP URL
+
+1. System Settings → Printers & Scanners → Add Printer…
+2. IP tab
+3. Address: `PRINT_SERVER_IP`
+4. Protocol: `Internet Printing Protocol - IPP`
+5. Queue: `printers/OmnivorePDF`
+
+When you print to this printer, PDFs should appear in Omnivore automatically.
+
+## Troubleshooting
+
+- Verify the services:
+ - `docker compose logs -f print-server`
+ - `docker compose logs -f print-watcher`
+- If uploads fail, confirm:
+ - `OMNIVORE_API_KEY` is present in your compose `.env`.
+ - `OMNIVORE_GRAPHQL_ENDPOINT` is reachable from the watcher container (defaults to `http://api:8080/api/graphql`).
diff --git a/packages/print/ippserver/Dockerfile b/packages/print/ippserver/Dockerfile
new file mode 100644
index 000000000..71f50832b
--- /dev/null
+++ b/packages/print/ippserver/Dockerfile
@@ -0,0 +1,24 @@
+FROM debian:bookworm-slim
+
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ cups \
+ cups-pdf \
+ avahi-daemon \
+ avahi-utils \
+ libnss-mdns \
+ dbus \
+ ghostscript \
+ procps \
+ && rm -rf /var/lib/apt/lists/*
+
+COPY entrypoint.sh /entrypoint.sh
+RUN chmod +x /entrypoint.sh
+
+ENV PRINT_SPOOL_DIR=/spool/incoming
+ENV PRINTER_NAME=OmnivorePDF
+
+VOLUME ["/spool"]
+
+EXPOSE 631/tcp 5353/udp
+
+CMD ["/entrypoint.sh"]
diff --git a/packages/print/ippserver/entrypoint.sh b/packages/print/ippserver/entrypoint.sh
new file mode 100644
index 000000000..7afad25c8
--- /dev/null
+++ b/packages/print/ippserver/entrypoint.sh
@@ -0,0 +1,103 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+SPOOL_DIR="${PRINT_SPOOL_DIR:-/spool/incoming}"
+PRINTER_NAME="${PRINTER_NAME:-OmnivorePDF}"
+
+mkdir -p "$SPOOL_DIR"
+
+# Configure cups-pdf output directory.
+if [[ -f /etc/cups/cups-pdf.conf ]]; then
+ # Default config ships with "#Out ...". Un-comment (or replace) it so output goes
+ # into the shared spool volume.
+ sed -i -E "s|^[[:space:]]*#?[[:space:]]*Out[[:space:]]+.*$|Out ${SPOOL_DIR}|" /etc/cups/cups-pdf.conf || true
+fi
+
+# Configure CUPS to listen on LAN + allow printing + advertise via DNS-SD.
+if [[ -f /etc/cups/cupsd.conf ]]; then
+ tmp="$(mktemp)"
+ awk '
+ BEGIN { in_root=0; found_printers=0 }
+ $0 == "Listen localhost:631" { print "Port 631"; next }
+ $0 == "" {
+ print ""
+ print " Order allow,deny"
+ print " Allow all"
+ print ""
+ in_root=1
+ next
+ }
+ in_root {
+ if ($0 == "") in_root=0
+ next
+ }
+ $0 == "" { found_printers=1 }
+ { print }
+ END {
+ if (!found_printers) {
+ print ""
+ print ""
+ print " Order allow,deny"
+ print " Allow all"
+ print ""
+ }
+ }
+ ' /etc/cups/cupsd.conf > "$tmp"
+ mv "$tmp" /etc/cups/cupsd.conf
+
+ if ! grep -q '^ServerAlias \\*$' /etc/cups/cupsd.conf; then
+ echo 'ServerAlias *' >> /etc/cups/cupsd.conf
+ fi
+ if ! grep -q '^DefaultShared Yes$' /etc/cups/cupsd.conf; then
+ echo 'DefaultShared Yes' >> /etc/cups/cupsd.conf
+ fi
+ if ! grep -q '^Browsing On$' /etc/cups/cupsd.conf; then
+ echo 'Browsing On' >> /etc/cups/cupsd.conf
+ fi
+ if ! grep -q '^BrowseLocalProtocols dnssd$' /etc/cups/cupsd.conf; then
+ echo 'BrowseLocalProtocols dnssd' >> /etc/cups/cupsd.conf
+ fi
+fi
+
+# Avahi requires a system dbus.
+mkdir -p /run/dbus
+dbus-daemon --system --fork
+
+# Ensure avahi runs without chroot (common in containers).
+mkdir -p /run/avahi-daemon
+avahi-daemon --no-chroot --daemonize || true
+
+# Start CUPS in the background to allow printer setup.
+cupsd -f &
+CUPSD_PID=$!
+
+# Wait briefly for cupsd socket.
+for _ in $(seq 1 20); do
+ if lpstat -r >/dev/null 2>&1; then
+ break
+ fi
+ sleep 0.2
+done
+
+# Create the PDF printer if missing.
+if ! lpstat -p "${PRINTER_NAME}" >/dev/null 2>&1; then
+ PPD="$(find /usr/share -maxdepth 6 -type f -iname '*cups-pdf*.ppd' -print -quit 2>/dev/null || true)"
+ if [[ -z "$PPD" ]]; then
+ PPD="$(find /usr/share -maxdepth 6 -type f -iname '*CUPS-PDF*.ppd' -print -quit 2>/dev/null || true)"
+ fi
+
+ if [[ -n "$PPD" && -f "$PPD" ]]; then
+ lpadmin -p "${PRINTER_NAME}" -E -v "cups-pdf:/" -P "$PPD" -o printer-is-shared=true || true
+ else
+ # If no PPD is found, we can still create the queue but driver selection may vary by client.
+ lpadmin -p "${PRINTER_NAME}" -E -v "cups-pdf:/" -m raw -o printer-is-shared=true || true
+ fi
+
+ cupsenable "${PRINTER_NAME}" || true
+ cupsaccept "${PRINTER_NAME}" || true
+fi
+
+echo "CUPS ready. Printer: ${PRINTER_NAME}. Spool: ${SPOOL_DIR}"
+
+# Keep the container running.
+wait "${CUPSD_PID}"
diff --git a/packages/print/package.json b/packages/print/package.json
new file mode 100644
index 000000000..62726d2fc
--- /dev/null
+++ b/packages/print/package.json
@@ -0,0 +1,6 @@
+{
+ "name": "@omnivore/print",
+ "private": true,
+ "version": "0.0.0",
+ "description": "Docker-based print-to-Omnivore pipeline assets (IPP server + watcher)."
+}
diff --git a/packages/print/watcher/Dockerfile b/packages/print/watcher/Dockerfile
new file mode 100644
index 000000000..a210a86fe
--- /dev/null
+++ b/packages/print/watcher/Dockerfile
@@ -0,0 +1,17 @@
+FROM alpine:3.19
+
+RUN apk add --no-cache bash curl jq inotify-tools coreutils ca-certificates
+
+COPY omnivore-print-upload.sh /usr/local/bin/omnivore-print-upload
+COPY omnivore-print-watch.sh /usr/local/bin/omnivore-print-watch
+
+RUN chmod +x /usr/local/bin/omnivore-print-upload /usr/local/bin/omnivore-print-watch
+
+ENV OMNIVORE_GRAPHQL_ENDPOINT=http://api:8080/api/graphql
+ENV PRINT_SPOOL_DIR=/spool/incoming
+ENV PRINT_PROCESSING_DIR=/spool/processing
+ENV PRINT_DONE_DIR=/spool/done
+ENV PRINT_FAILED_DIR=/spool/failed
+
+CMD ["/usr/local/bin/omnivore-print-watch"]
+
diff --git a/packages/print/watcher/omnivore-print-upload.sh b/packages/print/watcher/omnivore-print-upload.sh
new file mode 100644
index 000000000..f2cb4fdee
--- /dev/null
+++ b/packages/print/watcher/omnivore-print-upload.sh
@@ -0,0 +1,100 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+if [[ $# -ne 1 ]]; then
+ echo "usage: omnivore-print-upload " >&2
+ exit 2
+fi
+
+PDF_PATH="$1"
+
+if [[ ! -f "$PDF_PATH" ]]; then
+ echo "file not found: $PDF_PATH" >&2
+ exit 2
+fi
+
+if [[ "${PDF_PATH,,}" != *.pdf ]]; then
+ echo "not a pdf: $PDF_PATH" >&2
+ exit 2
+fi
+
+if [[ -z "${OMNIVORE_API_KEY:-}" ]]; then
+ echo "Missing OMNIVORE_API_KEY" >&2
+ exit 2
+fi
+
+GRAPHQL_ENDPOINT="${OMNIVORE_GRAPHQL_ENDPOINT:-http://api:8080/api/graphql}"
+
+base="$(basename "$PDF_PATH")"
+encoded_base="$(printf '%s' "$base" | jq -sRr @uri)"
+file_url="file:///omnivore-print/${encoded_base}"
+
+UPLOAD_MUTATION='mutation UploadFileRequest($input: UploadFileRequestInput!) { uploadFileRequest(input:$input) { __typename ... on UploadFileRequestSuccess { id uploadSignedUrl createdPageId } ... on UploadFileRequestError { errorCodes } } }'
+SAVE_MUTATION='mutation SaveFile($input: SaveFileInput!) { saveFile(input:$input) { __typename ... on SaveSuccess { url clientRequestId } ... on SaveError { errorCodes message } } }'
+
+graphql() {
+ local payload="$1"
+ curl -sS \
+ -H "Content-Type: application/json" \
+ -H "Omnivore-Authorization: ${OMNIVORE_API_KEY}" \
+ -H "User-Agent: omnivore-print-watcher/1.0" \
+ --data "$payload" \
+ "$GRAPHQL_ENDPOINT"
+}
+
+upload_payload="$(
+ jq -nc \
+ --arg query "$UPLOAD_MUTATION" \
+ --arg url "$file_url" \
+ --arg contentType "application/pdf" \
+ '{query:$query, variables:{input:{url:$url, contentType:$contentType, createPageEntry:true}}}'
+)"
+
+upload_resp="$(graphql "$upload_payload")"
+
+if [[ "$(jq -r '.errors | length // 0' <<<"$upload_resp")" != "0" ]]; then
+ echo "uploadFileRequest graphql errors: $(jq -c '.errors' <<<"$upload_resp")" >&2
+ exit 1
+fi
+
+upload_typename="$(jq -r '.data.uploadFileRequest.__typename // empty' <<<"$upload_resp")"
+if [[ "$upload_typename" != "UploadFileRequestSuccess" ]]; then
+ echo "uploadFileRequest failed: $(jq -c '.data.uploadFileRequest' <<<"$upload_resp")" >&2
+ exit 1
+fi
+
+upload_file_id="$(jq -r '.data.uploadFileRequest.id' <<<"$upload_resp")"
+upload_signed_url="$(jq -r '.data.uploadFileRequest.uploadSignedUrl' <<<"$upload_resp")"
+created_page_id="$(jq -r '.data.uploadFileRequest.createdPageId' <<<"$upload_resp")"
+
+curl -sS --fail \
+ -X PUT \
+ -H "Content-Type: application/pdf" \
+ --data-binary @"$PDF_PATH" \
+ "$upload_signed_url" >/dev/null
+
+save_payload="$(
+ jq -nc \
+ --arg query "$SAVE_MUTATION" \
+ --arg url "$file_url" \
+ --arg source "print" \
+ --arg clientRequestId "$created_page_id" \
+ --arg uploadFileId "$upload_file_id" \
+ '{query:$query, variables:{input:{url:$url, source:$source, clientRequestId:$clientRequestId, uploadFileId:$uploadFileId}}}'
+)"
+
+save_resp="$(graphql "$save_payload")"
+
+if [[ "$(jq -r '.errors | length // 0' <<<"$save_resp")" != "0" ]]; then
+ echo "saveFile graphql errors: $(jq -c '.errors' <<<"$save_resp")" >&2
+ exit 1
+fi
+
+save_typename="$(jq -r '.data.saveFile.__typename // empty' <<<"$save_resp")"
+if [[ "$save_typename" != "SaveSuccess" ]]; then
+ echo "saveFile failed: $(jq -c '.data.saveFile' <<<"$save_resp")" >&2
+ exit 1
+fi
+
+echo "uploaded ${base} -> pageId=${created_page_id}"
+
diff --git a/packages/print/watcher/omnivore-print-watch.sh b/packages/print/watcher/omnivore-print-watch.sh
new file mode 100644
index 000000000..26c7e26cd
--- /dev/null
+++ b/packages/print/watcher/omnivore-print-watch.sh
@@ -0,0 +1,59 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+INCOMING_DIR="${PRINT_SPOOL_DIR:-/spool/incoming}"
+PROCESSING_DIR="${PRINT_PROCESSING_DIR:-/spool/processing}"
+DONE_DIR="${PRINT_DONE_DIR:-/spool/done}"
+FAILED_DIR="${PRINT_FAILED_DIR:-/spool/failed}"
+
+mkdir -p "$INCOMING_DIR" "$PROCESSING_DIR" "$DONE_DIR" "$FAILED_DIR"
+
+unique_dest() {
+ local dir="$1"
+ local base="$2"
+ local dest="${dir}/${base}"
+ if [[ ! -e "$dest" ]]; then
+ echo "$dest"
+ return
+ fi
+ local ts
+ ts="$(date -u +%Y%m%dT%H%M%SZ)"
+ echo "${dir}/${ts}.$$-${base}"
+}
+
+process_one() {
+ local src="$1"
+ if [[ ! -f "$src" ]]; then
+ return 0
+ fi
+ if [[ "${src,,}" != *.pdf ]]; then
+ return 0
+ fi
+
+ local base processing done failed
+ base="$(basename "$src")"
+ processing="$(unique_dest "$PROCESSING_DIR" "$base")"
+ mv "$src" "$processing"
+
+ if /usr/local/bin/omnivore-print-upload "$processing"; then
+ done="$(unique_dest "$DONE_DIR" "$(basename "$processing")")"
+ mv "$processing" "$done"
+ else
+ failed="$(unique_dest "$FAILED_DIR" "$(basename "$processing")")"
+ mv "$processing" "$failed"
+ fi
+}
+
+while IFS= read -r -d '' f; do
+ process_one "$f"
+done < <(find "$INCOMING_DIR" -type f \( -iname '*.pdf' -o -iname '*.PDF' \) -print0 2>/dev/null || true)
+
+echo "watching $INCOMING_DIR for printed PDFs..."
+
+inotifywait -m -r \
+ -e close_write \
+ -e moved_to \
+ --format '%w%f' \
+ "$INCOMING_DIR" | while read -r path; do
+ process_one "$path"
+ done