Sign inSign up

aeternalabshq/pullmd

By aeternalabshq

•Updated 2 days ago

Self-hosted URL-to-Markdown service with stable, refreshable share links.

Image
1

10K+

aeternalabshq/pullmd repository overview

⁠PullMD

Release Docker Pulls CI License MCP

Anything in, clean Markdown out. Self-hosted conversion of web pages, documents, images, audio and YouTube videos into token-lean Markdown, for you and for your AI agents.

PullMD web interface (light and dark theme)

Quick start⁠ · Use with AI agents⁠ · API⁠ · Configuration⁠ · Changelog⁠


⁠Why PullMD

Agents are bad at reading the web. Raw HTML burns context on navigation, cookie banners and scripts, and built-in fetch tools often stumble on JavaScript-heavy pages, PDFs and comment threads. PullMD sits in between: one URL or file in, clean Markdown out, with source and metadata in YAML frontmatter instead of the body.

curl "https://pullmd.example.com/api?url=https://example.com/article"

What that saves, measured on three public pages (tokens counted with the cl100k_base tokenizer, 2026-10-16):

PagePlain curlPullMDPullMD with ?query=
Encyclopedia article (long-form)99,45812,549662
Developer documentation page48,6116,591454
Reddit thread with commentsblocked (bot check), no thread content6,381644

The curl column is what a naive fetch tool hands an agent. The last column is query-scoped extraction⁠: only the sections relevant to a question.

⁠Highlights

Reads almost anything

  • Web pages through a fallback chain: Cloudflare's native Markdown, then Readability + Trafilatura (best result wins), then headless Chromium for JavaScript-heavy sites
  • Reddit and Hacker News threads with their comment trees (depth and count configurable)
  • Documents: PDF, Word, PowerPoint, Excel, EPUB, by URL or upload, plus an optional OCR tier for table-grade PDFs
  • YouTube title, description and transcript with clickable timecodes, no API key needed
  • Images and audio via any OpenAI-compatible or local model (captions, transcripts)

Built for agents

  • MCP server for Claude Code and other MCP clients, with OAuth 2.1 for claude.ai and Claude Desktop
  • Query-scoped extraction: ?query= returns only the sections relevant to a question, typically 70-95% fewer tokens on long pages
  • Lean output: clean body, metadata in frontmatter, unpadded tables, configurable field allowlist
  • Live share links: /s/:id re-fetches when the cached copy is older than an hour and serves the last good snapshot if the source dies

Comfortable to self-host

  • One docker compose up, zero config, multi-arch images (amd64/arm64) on Docker Hub and GHCR
  • Every sidecar is optional, and PullMD degrades gracefully without it
  • Auth modes from open homelab to multi-user, SSRF protection, rate limits, a health endpoint for Uptime Kuma
  • Per-site extraction recipes as declarative JSON

Four ways in: PWA · REST API · MCP server · Claude Code skill


⁠Quick start

mkdir pullmd && cd pullmd
curl -O https://raw.githubusercontent.com/AeternaLabsHQ/pullmd/main/docker-compose.yml
docker compose up -d
# → http://localhost:3000

No .env needed: every variable has a sensible default. Add a .env next to the compose file to override anything (see Configuration⁠). Then connect your agent: see AI-agent integration⁠.

Upgrading? v3's only breaking change is the clean-body output format (one-line opt-out in MIGRATION.md⁠). Running behind a reverse proxy? Since v3.13 you need PULLMD_TRUST_PROXY⁠. Full history in the changelog⁠.

⁠docker-compose.yml (zero-config, abridged)
services:
  pullmd:
    image: aeternalabshq/pullmd:latest
    container_name: pullmd
    restart: unless-stopped
    ports:
      - "${PORT:-3000}:3000"
    environment:
      - PUBLIC_URL=${PUBLIC_URL:-http://localhost:${PORT:-3000}}
      - TRAFILATURA_URL=http://trafilatura:8001/extract
      - PLAYWRIGHT_URL=http://playwright:8002/render
      - MARKITDOWN_URL=http://markitdown:8003/convert
      - CACHE_DB=/data/cache.db
    volumes:
      - ./data:/data
    networks:
      - pullmd-internal
    depends_on:
      - trafilatura
      - playwright
      - markitdown

  trafilatura:
    image: aeternalabshq/pullmd-trafilatura:latest
    container_name: pullmd-trafilatura
    restart: unless-stopped
    networks:
      - pullmd-internal

  playwright:
    image: aeternalabshq/pullmd-playwright:latest
    container_name: pullmd-playwright
    restart: unless-stopped
    networks:
      - pullmd-internal

  markitdown:
    image: aeternalabshq/pullmd-markitdown:latest
    container_name: pullmd-markitdown
    restart: unless-stopped
    mem_limit: ${MARKITDOWN_MEM_LIMIT:-1g}
    networks:
      - pullmd-internal

networks:
  pullmd-internal:
    driver: bridge

Abridged for readability — the docker-compose.yml in the repo⁠ additionally passes every optional .env variable through to the containers (Reddit credentials, auth, media/OCR keys, YouTube options, output shaping). Use the curl -O command above rather than copying this block, or .env overrides beyond the basics won't reach the containers.

Note: the Playwright sidecar adds ~3.7 GB to your image cache (Chromium + Firefox + WebKit binaries from the official Playwright base image). It's optional — leave PLAYWRIGHT_URL unset and the playwright service block off, and PullMD silently degrades to static extraction with a fallback note in the metadata.

Note: the MarkItDown sidecar is optional. Leave MARKITDOWN_URL unset and remove the markitdown service block to disable document conversion. Web-page URLs always work without it.

Mirror on GHCR: ghcr.io/aeternalabshq/{pullmd,pullmd-trafilatura,pullmd-playwright,pullmd-markitdown}. Replace the image: lines if you prefer GitHub's registry.

⁠Behind Traefik

For deployments behind Traefik with TLS, use docker-compose.traefik.yml instead. Same images, but with Traefik labels and the proxy external network. Set HOST_DOMAIN in .env:

curl -O https://raw.githubusercontent.com/AeternaLabsHQ/pullmd/main/docker-compose.traefik.yml
echo "HOST_DOMAIN=pullmd.example.com" > .env
docker compose -f docker-compose.traefik.yml up -d

This file sets PULLMD_TRUST_PROXY=1: Traefik is the only peer (no port is published), so PullMD takes the client address and the https scheme from Traefik's forwarding headers.

⁠Behind another reverse proxy

PullMD ignores X-Forwarded-For, X-Forwarded-Proto and X-Forwarded-Host unless PULLMD_TRUST_PROXY says the connecting peer is a proxy. Behind nginx, Caddy, a tunnel or a load balancer, set:

PULLMD_TRUST_PROXY=1                          # one proxy hop, port not reachable directly
# or: PULLMD_TRUST_PROXY=172.18.0.0/16       # the proxy's address/network
PUBLIC_URL=https://pullmd.example.com

A hop count trusts whatever connects, so use it only when the container port is not published to anyone but the proxy; otherwise list the proxy's address. Without the setting, all clients share one rate-limit bucket (login, signup, share-link misses, OAuth, media budget) and PullMD logs a one-time warning. PUBLIC_URL keeps the session cookie Secure and is accepted as this site's host by the login form check. Do not send Referrer-Policy: no-referrer for PullMD's pages: browsers then post forms with Origin: null, which is refused. See MIGRATION.md⁠.

⁠Local development (no Docker)
git clone https://github.com/AeternaLabsHQ/pullmd.git
cd pullmd
npm install
npm start             # http://localhost:3000
npm test              # node --test

⁠Configuration

All variables go in .env (copy from .env.example):

v3.0.0 output format change: the markdown body is clean by default - just # Title followed by content. The source URL, fetch date, and all extraction metadata remain in the YAML frontmatter unchanged - the body no longer duplicates them. Set PULLMD_SOURCE_HEADER=true to restore the old inline header. Use PULLMD_FRONTMATTER_FIELDS to pick which frontmatter fields are emitted (handy for trimming tokens in agent pipelines).

VariableRequiredPurpose
HOST_DOMAINTraefik variant onlyPublic hostname without scheme. Used by Traefik routing and as fallback for PUBLIC_URL. Unused by the default compose.
PUBLIC_URLnoFull public origin embedded in /help and the skill zip. Defaults to https://${HOST_DOMAIN}. An https:// value also marks the session cookie Secure, and its host is accepted by the same-origin check on login and other form posts.
PULLMD_TRUST_PROXYbehind a reverse proxyWhen to honour X-Forwarded-*. Off by default. 1, 2, ... = hop count; true = every hop (the client address is then the first X-Forwarded-For entry, which the client can set; prefer a hop count); or a comma-separated list of addresses, CIDRs or loopback / linklocal / uniquelocal. An invalid list warns and stays off. docker-compose.traefik.yml sets 1. See Behind another reverse proxy⁠.
TRAFILATURA_URLnoURL of the Trafilatura sidecar's /extract endpoint. Unset → skip Trafilatura, Readability only.
PLAYWRIGHT_URLnoURL of the Playwright sidecar's /render endpoint. Unset → skip Playwright fallback for JS pages.
MARKITDOWN_URLnoURL of the MarkItDown sidecar's /convert endpoint. Unset → document-conversion path disabled; POST /api/file returns 502.
PULLMD_VISION_API_KEY / …_BASE_URL / …_MODELnoImage captioning via an OpenAI-compatible vision endpoint. Enabled when the key is set. _MODEL defaults to gpt-4o-mini.
PULLMD_STT_API_KEY / …_BASE_URL / …_MODELnoAudio transcription via an OpenAI-compatible /audio/transcriptions endpoint. Enabled when the key is set. _MODEL defaults to whisper-1.
PULLMD_LLM_API_KEY / …_BASE_URLnoShared fallback credentials for vision + STT when the per-modality vars are unset. Key and base URL only - there is no PULLMD_LLM_MODEL, and setting one is ignored (the server warns at startup).
PULLMD_PDF_OCR_API_KEY / …_BASE_URL / …_MODELnoOpt-in high-quality PDF→Markdown via an OCR provider that preserves tables (reference: Mistral OCR mistral-ocr-latest). Triggered per request with ?pdf=ocr or a recipe fetch.pdf: ocr. Default PDF handling stays the free markitdown path. _MODEL defaults to mistral-ocr-latest.
PULLMD_LLM_RATE_LIMITnoHourly budget shared by image captioning, audio transcription and PDF OCR, per signed-in user or client address. Default 30; 0 = off. Over budget the request falls back to the no-provider path, and that result is not cached.
MARKITDOWN_YOUTUBEnoSet to true to route YouTube URLs through the markitdown sidecar (returns title + description + transcript). No API key required. Default: off.
MARKITDOWN_YT_TIMECODESno (sidecar)Default timecode format in transcripts: links (YouTube timestamp links, default), plain (bare [MM:SS] labels), none (transcript text only). Overridable per-request via ?yt_timecodes=.
MARKITDOWN_YT_CHUNKno (sidecar)Transcript block size in seconds (default 30). 0 keeps the original per-snippet granularity. Overridable per-request via ?yt_chunk=.
MARKITDOWN_YT_LANGSno (sidecar)Comma-separated preferred transcript languages (e.g. de,en). Falls back to the first available language if none of the preferred ones exist.
MARKITDOWN_YT_PROXYno (sidecar)HTTP(S) proxy URL for YouTube requests. Datacenter IP addresses are often rate-limited by YouTube's transcript API; a residential or ISP proxy can help.
REDDIT_CLIENT_IDnoOAuth credentials for Reddit. Without them, PullMD uses the public JSON API (lower rate limit).
REDDIT_CLIENT_SECRETno
REDDIT_USER_AGENTnoReddit requires a unique UA. Default: PullMD/1.0 (URL-to-Markdown service).
DISABLE_PUBLIC_HISTORYnoWhen true, hides the global recent-conversions list and archive (/api/history + /api/archive return 403, the MCP list_recent tool returns an error, /api/stats omits its per-domain lists, frontend hides the section). /s/:id share links keep working. Default: false.
PULLMD_USER_AGENTnoPin a single outbound User-Agent for every web fetch. Disables rotation. Useful for CI or when one specific UA is known to work.
PULLMD_UA_FEED_URLnoURL of a JSON feed of current real-world UAs. Default: WinFuture23/real-world-user-agents⁠. Set to an empty string to disable live refresh and rely on the built-in seed pool.
PULLMD_AUTH_MODEnodisabled (default) / single-admin / multi-user. See "Authentication" below.
PULLMD_ALLOW_SIGNUPnoSelf-registration in multi-user mode. Default: on. false / 0 / no / off closes /signup (404) and removes the "create an account" link from the login page. Accounts can still be created with node scripts/admin.js create-user <email>.
PULLMD_ADMIN_EMAILrequired when AUTH_MODE != disabled, on first startupBootstrap email for the first admin user.
PULLMD_ADMIN_PASSWORDrequired when AUTH_MODE != disabled, on first startupBootstrap password (min 8 chars).
PULLMD_AUTH_TOKENnoLegacy bearer token compat (single-admin mode only, deprecated).
PULLMD_SOURCE_HEADERnoSet to true to restore the legacy inline source header in the body (# Title + **domain** · date + url; for Reddit the **r/sub** · u/user · N ↑ line). Default (unset): clean body - just the H1 title; source/date/post meta live in the frontmatter.
PULLMD_FRONTMATTER_FIELDSnoComma-separated allowlist of frontmatter fields to emit (e.g. title,url,source,llm_tokens). Unset = all fields. Trims tokens. Unknown names are ignored with a startup warning.
PULLMD_ALLOWED_HOSTSnoComma-separated CIDRs and/or exact hostnames that may be fetched even though they resolve into a blocked range. Empty by default = every internal target is blocked. See SSRF protection⁠.
PULLMD_MAX_FETCH_BYTESnoSize limit in bytes for fetched pages and documents and for the Playwright sidecar's response. Default 52428800 (50 MB). A larger response fails with an extraction error. A page fetch also has to deliver headers plus body within 60 s.
PULLMD_SITE_RECIPESnoPath to a JSON file of extra site recipes⁠, merged on top of the built-ins. Alternative to data/site-recipes.json.
PULLMD_FILENAME_DATE_PREFIXnoPrefix template for the suggested download filename (X-Suggested-Filename). Unset = no prefix. Tokens YYYY MM DD HH mm ss are substituted in local time, all other characters pass through; anything outside A-Za-z0-9._- ends up as a hyphen. Example: YYYY-MM-DD-HH-mm-ss- gives 2026-08-01-13-33-42-YT-some-talk-dQw4w9WgXcQ.md.
PULLMD_CACHE_RETENTION_DAYSnoHow long cache rows survive without a re-fetch, in days. Default: 90. 0 = unlimited: nothing is ever pruned, so the cache doubles as an archive. The accepted range is 0 to 36500 (100 years); anything else warns once at startup and falls back to 90. Share links expire with their row - /s/:id stops resolving once the row is older than the retention window. Lowering the value on a running instance prunes every row older than the new value on the next cache write (any conversion).
PULLMD_COVERAGE_GUARDnoSet to off to disable the coverage guard. Default (unset): on. The guard notices when an extraction kept only a sliver of the page - the failure mode of page-builder one-pagers, whose chapters sit in flat sibling containers that Readability's single-candidate scoring discards - and re-converts the container holding the body instead. It only ever grows the result, records source: coverage-guard, and explains itself in metadata.extractorReason.

PUBLIC_URL matters for self-hosting: the help page and downloadable skill embed it as the canonical endpoint. Set it correctly and your users get a copy-paste setup that points at your instance.

PullMD rotates its outbound User-Agent for the web fetch path from a pool of current desktop browsers, refreshed every 48 hours from a live feed of real-world UAs⁠ maintained by @WinFuture23⁠. A built-in seed pool ensures rotation works even when the feed is unreachable. Set PULLMD_USER_AGENT to pin a single UA, or PULLMD_UA_FEED_URL to point at your own feed. The Reddit path keeps its dedicated REDDIT_USER_AGENT because Reddit's API expects a stable, identifying UA.

DISABLE_PUBLIC_HISTORY=true is the privacy switch for shared instances (multi-tenant VPS, office deployments). Conversions still get cached and assigned share IDs; users just can't see what other users have fetched (the MCP list_recent tool follows the same rule, and /api/stats leaves out its per-domain lists). Anyone with a known /s/:id link still gets their markdown back. Share ids are 128 random bits, so a link cannot be guessed, and /s/:id throttles unknown-id lookups to 120 per minute and IP (valid links are never throttled). With sign-in enabled (PULLMD_AUTH_MODE=single-admin or multi-user) history is scoped per user anyway; the switch matters for instances without sign-in.


⁠Authentication (v2.0+)

Version pinning: :latest tracks the newest release (v3). v3's only breaking change is the clean-body output format⁠ — to stay on the v2.x output format instead, pin the explicit major tag:

services:
  pullmd:
    image: aeternalabshq/pullmd:2

PullMD ships with three auth modes. Pick one with PULLMD_AUTH_MODE:

ModeBehavior
disabledDefault. No auth, everything open. Existing v1.x behavior.
single-adminOne user, credentials from env vars. No self-signup. For homelab.
multi-userSelf-signup at /signup (unless PULLMD_ALLOW_SIGNUP is off), login at /login, per-user data isolation.

In single-admin and multi-user modes, PULLMD_ADMIN_EMAIL + PULLMD_ADMIN_PASSWORD bootstrap the first admin user on first startup. After that, changing these env vars does not change the password — use the admin CLI:

docker compose exec pullmd node scripts/admin.js reset-password [email protected]

Create an account without opening self-registration (useful when PULLMD_ALLOW_SIGNUP is off):

docker compose exec pullmd node scripts/admin.js create-user [email protected]

Both commands read the password from stdin, so they need it attached: docker compose exec, docker exec -it, or a pipe (echo "…" | docker exec -i <container> node scripts/admin.js …). A plain docker exec without -i aborts with an error and exit code 2 instead of doing nothing.

⁠Auth boundary
EndpointAuth required (when mode != disabled)
/, /help, static assets, /pullmd.zipno
/login, /signup, /api/me (auth surface)no
/s/:id (share links)no
/api, /api/streamyes
POST /api/html, POST /api/fileyes
/mcpyes
/api/history, /api/archiveyes
DELETE /api/cache/:id, DELETE /api/cacheyes
/api/stats, /api/storage, /api/config (aggregate)no
/api/status, /api/recipes/status (health)no

/api/stats returns its per-domain lists (lowQualityDomains, fallbackByDomain) only to the admin, or to everyone when auth is off and public history is on; the aggregate counts are public. Signed-in callers of /api/history and MCP list_recent get their own conversions.

Cache deletes are scoped to the caller. An admin (and every caller in disabled mode) removes the shared, URL-deduped cache row, which affects every user's history. A regular user only unlinks the entry from their own history - the shared row and its /s/:id share link keep working. The response says which happened via "scope": "user" | "global".

⁠Authentication paths
  1. Session cookies — POST /login sets pullmd_session (HttpOnly, SameSite=Lax, 90-day TTL with sliding expiry). The PWA uses this automatically. The cookie is Secure when the request arrived over HTTPS (directly, or through a proxy covered by PULLMD_TRUST_PROXY) or when PUBLIC_URL starts with https://. POST /login allows 10 attempts per minute and POST /signup 5 per hour per client address (429 with Retry-After beyond that). Login, signup, logout and OAuth consent form posts are accepted only when Origin (or Referer) names this host or the host of PUBLIC_URL; posts without either header are accepted.
  2. API keys — generate at /settings, send via Authorization: Bearer pmd_<32-char-base62>. Stored as SHA-256 hashes; only shown once at creation.
  3. Legacy PULLMD_AUTH_TOKEN — deprecated. single-admin mode only. Maps to admin user. Kept for migration compatibility; slated for removal in a future major release.
⁠Migration from v1.x

See MIGRATION.md for the full upgrade checklist. The TL;DR: leave PULLMD_AUTH_MODE unset and v2.0 behaves exactly like v1.x.

⁠OAuth 2.1 (claude.ai Web Connector)

PullMD ships with a full OAuth 2.1 Authorization Code flow so the claude.ai web app's Custom Connector feature can authenticate users against your PullMD instance. All endpoints needed by the spec are implemented: Dynamic Client Registration (RFC 7591), PKCE-S256 (RFC 7636), Authorization Server Metadata (RFC 8414), Protected Resource Metadata (RFC 9728), and Token Revocation (RFC 7009).

Setup:

  1. Set PULLMD_AUTH_MODE to single-admin or multi-user (OAuth requires Phase-1 auth).
  2. Set OAUTH_JWT_SECRET to a 32+ character random string (openssl rand -hex 32).
  3. Set PUBLIC_URL to your instance's public origin (e.g. https://pullmd.example.com).
  4. In claude.ai → Settings → Conn

Tag summary

Content type

Image

Digest

sha256:f89f3bc15…

Size

97.4 MB

Last updated

4 days ago

docker pull aeternalabshq/pullmd