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.

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!

Leave a Reply