Self-Hosting¶
Run your own copy of the Patcher catalog service. Community-supported.
The Patcher API is a FastAPI service that stitches macOS app patching metadata from Installomator, Homebrew Cask, AutoPkg, and Jamf App Installers into a single queryable catalog. The public deployment at https://api.patcherctl.dev is the easiest path. This page is for the cases where that isn’t a fit.
Who This Is For¶
Keep catalog reads inside your network for latency, audit, or policy reasons.
The container only needs network access when the ingest job runs. The serving side is offline-friendly.
Iterate on ingest sources, stitch rules, or the MCP server without touching production.
Fork the ingest pipeline to add internal sources, suppress upstreams, or change the refresh schedule.
Honest Framing¶
Important
The Dockerfile and the compose snippet on this page were drafted with AI assistance. The Patcher maintainer is not a Docker expert and does not run Patcher in containers in production (the public deployment uses systemd on a Linode VM). Treat this setup as community-supported. Improvements to the image, security posture, build performance, or compose layout are very welcome via pull request.
For the conceptual model of how the service is structured (FastAPI app, ingest pipeline, stitch layer, MCP sub-app), read Architecture first. This page covers operations, not internals.
Build the Image¶
The Dockerfile lives at the repo root. It uses uv inside a multi-stage build, materializes the patcher-api workspace member, and ships a non-root runtime user with a /health healthcheck baked in.
$ git clone https://github.com/liquidz00/Patcher.git
$ cd Patcher
$ docker build --tag patcher-api:local .
The default Python is 3.13. Override with --build-arg PYTHON_VERSION=3.12 if you need a different interpreter.
Run It¶
The image exposes port 8000 and stores the catalog at /data/patcher_api.db by default. Mount a volume there so the database survives container restarts.
$ docker run --rm \
--name patcher-api \
--publish 8000:8000 \
--volume patcher-catalog:/data \
--env PATCHER_API_ADMIN_TOKEN="change-me" \
patcher-api:local
Visit http://localhost:8000/health to confirm the service is up.
Important
Building and running the image does not populate the catalog. On first boot the container drops in a small SEED_ON_STARTUP sample (a handful of apps) so /apps isn’t empty, but the real catalog of thousands of apps comes only from the separate ingest step below. Don’t expect a full catalog straight from docker run.
Environment Variables¶
All runtime configuration uses the PATCHER_API_ prefix. The full list lives in api/patcher_api/config.py; the variables that matter most for self-hosting:
Variable |
Purpose |
Default |
|---|---|---|
|
SQLAlchemy URL for the catalog DB. The image defaults to a SQLite file at |
|
|
Shared secret gating |
unset |
|
JSON list of browser origins permitted to call |
|
|
Authenticates ingest-time calls to |
unset |
|
Idempotent smoke-seed of a few catalog rows on first boot. Safe to leave on. |
|
|
Path inside the container to read env vars from. Lets you mount a secrets file instead of passing |
|
Populate the Catalog¶
The serving image starts with an empty (or smoke-seeded) catalog. Real data comes from the ingest pipeline at api/scripts/ingest.py, which pulls each upstream source, writes per-source rows, and runs the stitch pass that joins them into canonical apps records.
A one-shot ingest against the same volume the API uses:
$ docker run --rm \
--volume patcher-catalog:/data \
--env PATCHER_API_DATABASE_URL="sqlite+aiosqlite:////data/patcher_api.db" \
--env PATCHER_API_GITHUB_TOKEN="ghp_..." \
--workdir /opt/patcher/api \
--entrypoint python \
patcher-api:local scripts/ingest.py all
all runs every source (Installomator, Homebrew Cask, AutoPkg, Jamf App Installers) and then the stitch phase. Each source can be run individually (installomator, homebrew, autopkg, jai, stitch); see the script’s --help for flags including --force (bypass SHA gating) and --resolve (evaluate Installomator’s dynamic downloadURL / appNewVersion expressions during ingest, slower but more complete).
Note
A full ingest currently takes 10 to 15 minutes against fresh upstreams. The public deployment runs it once a day on a systemd timer (api/deploy/patcher-catalog-refresh.timer). Schedule yours via cron, a Kubernetes CronJob, a host-side systemd.timer, or whatever your orchestrator prefers.
Warning
The API process caches the catalog SHA at startup and uses it as the ETag for /apps* responses. After a fresh ingest the running container will not pick up the new hash until it restarts. The reference compose file does not automate this; on the production VM a systemd ExecStartPost= restarts the API after the timer fires. On Docker you can either run the API with --restart always and trigger a manual restart after the ingest job, or wrap the ingest in a script that runs docker compose restart api on completion.
Compose Example¶
A reference docker-compose.yml lives at the repo root. It defines two services backed by a shared named volume:
services:
api:
build:
context: .
dockerfile: Dockerfile
image: patcher-api:local
restart: unless-stopped
ports:
- "8000:8000"
environment:
PATCHER_API_DATABASE_URL: "sqlite+aiosqlite:////data/patcher_api.db"
volumes:
- catalog:/data
healthcheck:
test: ["CMD", "curl", "--fail", "--silent", "http://127.0.0.1:8000/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 20s
ingest:
image: patcher-api:local
build:
context: .
dockerfile: Dockerfile
profiles: ["ingest"]
environment:
PATCHER_API_DATABASE_URL: "sqlite+aiosqlite:////data/patcher_api.db"
# PATCHER_API_GITHUB_TOKEN: "ghp_..."
volumes:
- catalog:/data
working_dir: /opt/patcher/api
entrypoint: ["/usr/bin/tini", "--"]
command: ["python", "scripts/ingest.py", "all"]
volumes:
catalog:
The ingest service uses the ingest profile so docker compose up only starts the API. Trigger an ingest on demand with:
$ docker compose --profile ingest run --rm ingest
Wire that command into your host’s cron or scheduler.
Caveats¶
A few things to know before this goes anywhere serious.
SQLite under multiple workers
The default uvicorn command runs a single worker. SQLite locking gets unhappy under a multi-worker uvicorn against the same file. If you need more concurrency, put a reverse proxy in front of multiple containers each with their own DB, or migrate the storage to Postgres (the SQLAlchemy models are Postgres-friendly, but this path is not regularly tested).
The seed_on_startup smoke data
First boot drops a small set of known apps into the catalog so /apps returns something before your first ingest finishes. Set PATCHER_API_SEED_ON_STARTUP=false if that’s not desired.
The MCP sub-app at /mcp
Hosted alongside the REST routes on the same port. If you don’t need it, point browser MCP clients elsewhere via PATCHER_API_MCP_ALLOWED_ORIGINS. The sub-app itself can’t be unmounted without a code change.
Image size and security
The reference image is a community starting point, not a hardened production base. There’s no distroless runtime, no SBOM step, no image-signing flow. If you ship this in a regulated environment, treat the Dockerfile as a draft to harden.
Contributing¶
If you run Patcher in a container and have hard-won fixes (smaller image, better caching, multi-arch builds, a real Helm chart, a Kubernetes manifest), open a PR. The Dockerfile and this page are explicitly community-improvable; see Contributing for the workflow.