Setting up a local plugin marketplace for Claude Code

I run a lot of Claude Code sessions, including dispatched sessions that spin up in short-lived k3s pods. I wanted those pods to install their Claude Code plugins from a server I control on my own network, not from github.com. The model I had in mind is a local yum mirror or a Red Hat Satellite: one internal source that every machine points at, so nothing on the client side reaches the public internet for a plugin.

It took a few wrong turns to get there, because a Claude Code plugin marketplace is not quite what you would guess. Here is what actually works.

Top-to-bottom request flow: client to reverse proxy to nginx to fcgiwrap to git-http-backend to the bare git repo
The request path, top to bottom: client, reverse proxy, nginx, fcgiwrap, git-http-backend, bare git repo.

A static file server does not work

My first assumption was that a marketplace is a static catalog file plus some tarballs, so any web server would do. It does not. Claude Code does a shallow clone of a marketplace, and a plain static (“dumb HTTP”) git endpoint cannot serve a shallow clone:

fatal: dumb http transport does not support shallow capabilities

So the marketplace has to be a real smart-HTTP git server. That one fact drives the whole design.

The smart-HTTP git server

The smart-HTTP git server is git’s own git-http-backend CGI, run behind nginx through fcgiwrap. The gotcha on Alpine: git-http-backend is not in the base git package, it ships in the git-daemon subpackage, and it lands at /usr/libexec/git-core/git-http-backend. If you forget that package, the binary is missing, fcgiwrap cannot exec the CGI, and every clone returns a 403 that looks like an export problem but is not.

FROM alpine:3.21
RUN apk add --no-cache git git-daemon nginx fcgiwrap spawn-fcgi tini
# assert the CGI exists so a base change fails the build loudly, not with 403s
RUN test -x /usr/libexec/git-core/git-http-backend

The nginx location that hands *.git requests to the backend:

location ~ ^/.*\.git(/.*)?$ {
    fastcgi_pass unix:/run/fcgiwrap.sock;
    include /etc/nginx/fastcgi_params;
    fastcgi_param SCRIPT_FILENAME /usr/libexec/git-core/git-http-backend;
    fastcgi_param GIT_HTTP_EXPORT_ALL "1";
    fastcgi_param GIT_PROJECT_ROOT /srv/git;
    fastcgi_param PATH_INFO $uri;
    client_max_body_size 0;
}

The repo the server hands out is a bare clone, marked exportable and clone-only:

git clone --bare /build/marketplace /srv/git/jbmurphy-plugins.git
cd /srv/git/jbmurphy-plugins.git
git update-server-info
git config http.receivepack false   # pulls only, no pushes
touch git-daemon-export-ok          # mark the repo exportable

How the plugins are packaged

A marketplace is a .claude-plugin/marketplace.json catalog plus the plugins it lists. Plugin sources can be github, a git URL, an npm package, or a relative path inside the marketplace repo. There is no "static tarball" source. To keep everything in one place and avoid a second fetch, I use relative-path sources, so cloning the marketplace brings the catalog and every plugin’s code in one operation:

{
  "name": "jbmurphy-plugins",
  "plugins": [
    { "name": "executor",   "source": "./executor" },
    { "name": "eventforge", "source": "./eventforge" }
  ]
}

One of my plugins is a Node/Bun MCP server with a real dependency tree. I did not want to commit node_modules (68 MB) or have the client run an install at plugin-install time. Bun bundles the whole thing into one self-contained file, dependencies inlined:

bun build ./index.ts --target=bun --outfile=./dist/index.js

That turns 68 MB of node_modules into a single 900 KB dist/index.js, and the plugin manifest points bun at that file. One note: keep package.json out of the served plugin directory. If it is there, Claude Code runs bun install at install time and re-fetches everything you just bundled away. I keep the build source in a separate directory that is not served.

Using it

On any machine, or in the pod entrypoint, it is two commands. The client only ever talks to the local host:

claude plugin marketplace add https://marketplace.local.jbmurphy.com/jbmurphy-plugins.git
claude plugin install eventforge@jbmurphy-plugins

The marketplace itself is a small nginx deployment in k3s behind my reverse proxy, so the client sees a normal HTTPS URL and the proxy forwards to the git server. One thing to check on the proxy: the "block common exploits" option can reject git’s smart-HTTP requests, because git uses paths and content types a generic WAF rule flags. I turned it off for that host.

Result

My dispatched pods now do a real plugin marketplace add and plugin install against the local server at startup, with correct install records and no dependency on github.com. Updating a plugin is a rebuild of the marketplace image; clients pick up the new version on the next install. The catalog and every plugin’s code come from one host I control, which was the whole point.

Hope that helps someone!


Posted

in

by

Tags:

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *