You want a package registry running on your own Mac. The reasons are usually one of these: hosting private packages that should not go to a public index, rehearsing a publish before it becomes irreversible, working offline or air-gapped, caching upstream packages so CI is not at the mercy of npmjs.com or PyPI, or sharing internal libraries across a small team without paying for SaaS.
The options range from a one-process container that does exactly one thing to a multi-gigabyte Java application that hosts a dozen package formats. This article rates the realistic contenders, then proves the top picks by actually publishing and installing real npm and Python packages against them. Docker is on the table throughout; every registry here runs as a container.
How the ratings were made
Each contender is judged on six axes:
- Formats — npm, PyPI, and anything else it hosts from one instance.
- Setup effort — how long from
docker runto a successfulpublish. - Footprint — image size and idle memory, measured on this machine.
- Auth and multi-user — tokens, users, per-package permissions.
- Web UI — browse packages, manage users, read logs.
- Upstream proxy — pull-through caching of the public index, the feature that makes a registry double as an offline cache.
The footprint and hands-on numbers below are from a real run on macOS (Darwin 25.5.0), Docker 29.6.1, Node 26.4.0 / npm 11.17.0, and uv 0.11.26. The rows that were not run locally (Nexus, GitLab, Artifactory, devpi) are rated from their documented requirements rather than a local run, and each contender’s profile below calls out how it was assessed.
The scorecard
| Registry | Formats | Setup | Idle RAM | Auth | Web UI | Upstream proxy | Best for |
|---|---|---|---|---|---|---|---|
| Verdaccio | npm (first-class) | trivial | ~70 MB | tokens, htpasswd | yes | yes (npmjs) | npm, solo or small team |
| Gitea / Forgejo | npm, PyPI, +10 more | easy | ~116 MB | tokens, full users | yes | no | multi-format on a laptop |
| Nexus Repository OSS | npm, PyPI, +many | moderate | multiple GB | full RBAC | excellent | yes | multi-format with RAM to spare |
| devpi | PyPI only | moderate | moderate | users, indexes | basic | yes (PyPI) | a serious Python shop |
| pypiserver | PyPI only | trivial | ~19 MB | htpasswd | none | no | the simplest private PyPI |
| GitLab CE | npm, PyPI, +many | heavy | multiple GB | full RBAC | excellent | limited | only if you already run GitLab |
| Artifactory OSS | Maven family only | moderate | multiple GB | full RBAC | good | Maven only | JVM shops (not npm/PyPI) |
| Static PEP 503 dir | PyPI (read-only) | trivial | ~0 | none | none | no | a frozen, read-only mirror |
The rest of the article explains each rating and demonstrates the four that earn a recommendation for npm and Python.
The contenders
Verdaccio — the npm default
Verdaccio is a lightweight npm registry that needs no
configuration to start. Run the container, register a user, and npm publish
works. Its headline feature is the uplink: an unknown package is proxied from
npmjs.com and cached, so Verdaccio doubles as an offline npm mirror. It has a
clean web UI and, through plugins, can serve other formats, but npm is what it is
built for and where it shines.
Rating. Best-in-class for npm. If your world is JavaScript, stop here. The only reason to look further is needing PyPI or other formats from the same instance, which Verdaccio does not do cleanly. Idle memory here was ~70 MB.
Gitea / Forgejo — multi-format that fits on a laptop
Gitea (and its community fork
Forgejo) is a Git host written in Go, and its built-in
package registry supports npm,
PyPI, Cargo, Composer, Conan, Container, Maven, NuGet, RubyGems, and more, all
from a single ~260 MB image that idles around 116 MB of RAM. Each format lives
under /api/packages/<owner>/<format>/, authenticated with a normal Gitea token.
You also get a real user system, a web UI, and, if you want it, Git hosting in
the same process.
Rating. The best all-round choice for a Mac that needs npm and Python (and maybe Cargo or Maven) without running a multi-gigabyte server. No pull-through proxy of the public indexes is the main gap; it hosts your packages, it does not cache npmjs.com or PyPI.
Sonatype Nexus Repository OSS — the full-featured heavyweight
Nexus Repository OSS is the long-standing self-hosted answer for many formats at once: npm, PyPI, Maven, Docker, NuGet, RubyGems, apt, yum, and more, with hosted, proxy, and group repositories and a polished admin UI. It is a Java application; its JVM defaults to roughly a 2.7 GB maximum heap and Sonatype suggests at least 4 GB of free RAM. Startup is tens of seconds, not one.
Rating. The strongest UI and proxy story of anything here, and genuinely production-grade, but the resource cost is real. Pick it when you want one serious registry for a team and the machine can spare the memory. Overkill for a solo laptop that just needs to host a few private packages.
devpi — the Python power tool
devpi is PyPI-only, and within that scope it is
excellent: pull-through caching of PyPI, per-user and staging indexes, index
inheritance, replication, and a devpi test workflow. It is more moving parts
than pypiserver and rewards a team that lives in Python.
Rating. The best private PyPI if Python is your center of gravity and you want caching and staging. It does nothing for npm.
pypiserver — the simplest private PyPI
pypiserver is a minimal PEP 503
index: upload wheels with twine, install with pip --index-url. No database,
no users beyond an htpasswd file, a 140 MB image that idled under 20 MB of RAM.
It does not proxy PyPI, so it is a host, not a cache.
Rating. Unbeatable for “I just need somewhere private to twine upload a few
wheels.” Reach for devpi instead when you outgrow it.
GitLab CE — only if it is already there
GitLab Community Edition has a capable multi-format package registry (npm, PyPI, Maven, NuGet, Conan, and others). But GitLab is a large multi-process application that wants several GB of RAM. Standing it up only to host packages is a poor trade.
Rating. Sensible if you already self-host GitLab for source; otherwise its weight rules it out against Gitea or Nexus.
JFrog Artifactory OSS — a format trap for this use case
Artifactory is excellent, but read the editions carefully. The free, self-hosted OSS edition supports only the Maven family (Maven, Gradle, Ivy, SBT) and generic repositories. npm, PyPI, Docker, and the rest require a paid tier. So for a Mac that needs npm and Python, self-hosted Artifactory OSS does not qualify, however good the paid product is.
Rating. Not a fit for npm/PyPI unless you pay. Do not start here expecting a
free multi-format registry; you will hit the paywall at the first npm publish.
Static PEP 503 directory — the zero-dependency baseline
pip can install from any directory of packages served over HTTP that follows
the PEP 503 “simple” layout, for example
python3 -m http.server over a folder of wheels. There is no upload API, no
auth, and nothing for npm. It is a read-only mirror you populate by hand.
Rating. Fine for a frozen, read-only set of wheels on an air-gapped box.
Not a registry you iterate against; the moment you want to publish, use
pypiserver instead.
Prerequisites for the hands-on
- macOS 13+ with Homebrew (brew.sh).
- Docker Desktop running (
docker versionshould print a server version). - Node.js 20+ and npm —
brew install node; the demos publish and install npm packages. - uv 0.5+ —
brew install uv; used to build the Python package and to runtwineviauvx. - Xcode Command Line Tools (
xcode-select --install) formake.
Proving the top picks
The rest of this article stands up the four recommended registries in one
docker compose file and runs a real publish-then-install for each. Every file
is shown in full inline below.
The compose file
Create the files
mkdir -p local-package-registry-macos
cd local-package-registry-macos
touch .gitignore docker-compose.yml
Add the code: .gitignore
# Secrets / generated tokens
.gitea-token
.npmrc
# Build artifacts
dist/
build/
*.egg-info/
node_modules/
# Throwaway consumer dirs created by the demos
.consumers/
# Python venvs
.venv/
*-venv/
# Caches
__pycache__/
*.py[cod]
.pytest_cache/
# OS / editor noise
.DS_Store
*.log
Add the code: docker-compose.yml
# Three local package registries, each on its own port. Bring up only what you
# need — `docker compose up -d verdaccio`, etc. Data persists in named volumes;
# `make reset` (docker compose down -v) wipes it.
services:
verdaccio:
image: verdaccio/verdaccio:6
ports:
- "4873:4873" # npm registry + web UI
volumes:
- verdaccio-storage:/verdaccio/storage
gitea:
image: gitea/gitea:1.24
environment:
- GITEA__security__INSTALL_LOCK=true # skip the browser install wizard
- GITEA__server__ROOT_URL=http://localhost:3001/
- GITEA__server__DOMAIN=localhost
ports:
- "3001:3000" # web UI + multi-format package API under /api/packages
volumes:
- gitea-data:/data
pypiserver:
image: pypiserver/pypiserver:latest
command: run -P . -a . # -P . -a . => no auth (laptop-local throwaway index)
ports:
- "8081:8080" # PyPI-compatible simple index
volumes:
- pypiserver-packages:/data/packages
volumes:
verdaccio-storage:
gitea-data:
pypiserver-packages:
Detailed breakdown
.gitignorecomes first and blocks the two things this project generates that must never be committed:.gitea-token(a live credential) and the per-package.npmrcfiles the demos write with tokens inside them.- Verdaccio needs no configuration; the default image is a working npm registry on port 4873.
- Gitea would normally show a browser install wizard on first boot.
GITEA__security__INSTALL_LOCK=trueskips it, andROOT_URLis set tohttp://localhost:3001/so generated package URLs point at the host port, not the container’s internal 3000. Port 3001 sidesteps the common collision with other local dev servers on 3000. - pypiserver’s
-P . -a .disables authentication. That is acceptable for a throwaway index bound to localhost; for anything shared, pass an htpasswd file instead. - Named volumes persist each registry’s data across restarts.
docker compose down -vwipes them for a clean slate.
The sample packages
Two tiny packages give every demo something real to publish.
Create the files
mkdir -p samples/npm-widget samples/py-widget/src/local_demo_pkg
touch samples/npm-widget/package.json samples/npm-widget/index.js
touch samples/py-widget/pyproject.toml \
samples/py-widget/src/local_demo_pkg/__init__.py
Add the code: samples/npm-widget/package.json
{
"name": "@local/widget",
"version": "1.0.0",
"description": "Sample package for local-registry demos",
"main": "index.js",
"license": "MIT"
}
Add the code: samples/npm-widget/index.js
module.exports = () => "widget served from a local registry";
Add the code: samples/py-widget/pyproject.toml
[project]
name = "local-demo-pkg"
version = "1.0.0"
description = "Sample package for local-registry demos"
requires-python = ">=3.9"
[build-system]
requires = ["uv_build>=0.11.26,<0.12.0"]
build-backend = "uv_build"
Add the code: samples/py-widget/src/local_demo_pkg/__init__.py
def hello(): return "python package served from a local registry"
Detailed breakdown
@local/widgetis a scoped npm package. The scope (@local) is what routes installs to the local registry: a consumer maps@local:registry=<url>and everything else still resolves from npmjs.com.local-demo-pkgis a standarduv_buildPython package that produces a wheel and sdist. The same built artifacts are uploaded to both Gitea and pypiserver, proving one build works against either backend.
Bootstrapping Gitea
Verdaccio and pypiserver need no user setup, but Gitea needs an account and a token before it will accept a package.
Create the file
mkdir -p scripts
touch scripts/gitea-bootstrap.sh
chmod +x scripts/gitea-bootstrap.sh
Add the code: scripts/gitea-bootstrap.sh
#!/usr/bin/env bash
# Create an admin user in the running Gitea container and mint a package-scoped
# token. Writes the token to .gitea-token (gitignored). Idempotent enough to
# re-run: the user create is a no-op if the user already exists.
set -euo pipefail
GITEA_URL=${GITEA_URL:-http://localhost:3001}
GITEA_USER=${GITEA_USER:-dev}
GITEA_PASS=${GITEA_PASS:-devpass123}
echo "Waiting for Gitea at $GITEA_URL ..."
for _ in $(seq 1 60); do
if [ "$(curl -s -o /dev/null -w '%{http_code}' "$GITEA_URL/api/healthz")" = "200" ]; then
break
fi
sleep 2
done
echo "Creating admin user '$GITEA_USER' (no-op if it exists) ..."
docker compose exec -u git gitea gitea admin user create \
--username "$GITEA_USER" --password "$GITEA_PASS" --email "$GITEA_USER@example.com" \
--admin --must-change-password=false 2>/dev/null || echo " user already exists"
echo "Minting a write:package / read:package token ..."
curl -s -X POST -u "$GITEA_USER:$GITEA_PASS" -H 'Content-Type: application/json' \
-d "{\"name\":\"pkg-$RANDOM\",\"scopes\":[\"write:package\",\"read:package\"]}" \
"$GITEA_URL/api/v1/users/$GITEA_USER/tokens" \
| python3 -c 'import sys,json; print(json.load(sys.stdin)["sha1"])' > .gitea-token
echo "Token written to .gitea-token"
Detailed breakdown
gitea admin user createruns inside the container overdocker compose exec, because it is a local admin command, not an API call. It is a no-op on re-run once the user exists.- The token is minted over the REST API with
write:packageandread:packagescopes, the least privilege needed to publish and install.pkg-$RANDOMkeeps the token name unique so repeated runs do not collide. - The token lands in
.gitea-token, which.gitignorealready blocks. The demos read it from there.
The Makefile
One target per registry, plus lifecycle commands. Plain make prints help.
Create the file
touch Makefile
Add the code: Makefile
.DEFAULT_GOAL := help
VERDACCIO ?= http://localhost:4873
GITEA ?= http://localhost:3001
PYPI ?= http://localhost:8081
.PHONY: help up down reset bootstrap-gitea \
demo-verdaccio demo-gitea-npm demo-gitea-pypi demo-pypiserver verify clean
help: ## Show this help screen
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
| awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-18s\033[0m %s\n", $$1, $$2}'
up: ## Start all three registries (docker compose up -d)
docker compose up -d
down: ## Stop the registries (keep data volumes)
docker compose down
reset: ## Stop the registries and wipe their data volumes
docker compose down -v
rm -f .gitea-token
bootstrap-gitea: ## Create the Gitea admin user and mint a package token
./scripts/gitea-bootstrap.sh
demo-verdaccio: ## Publish + install an npm package via Verdaccio
@TOKEN=$$(curl -s -X PUT -H 'Content-type: application/json' \
-d '{"name":"dev","password":"devpass123"}' \
$(VERDACCIO)/-/user/org.couchdb.user:dev \
| python3 -c 'import sys,json;print(json.load(sys.stdin)["token"])'); \
printf '@local:registry=%s/\n//localhost:4873/:_authToken=%s\n' "$(VERDACCIO)" "$$TOKEN" \
> samples/npm-widget/.npmrc; \
( cd samples/npm-widget && npm publish ); \
rm -rf .consumers/verdaccio && mkdir -p .consumers/verdaccio; \
printf '@local:registry=%s/\n' "$(VERDACCIO)" > .consumers/verdaccio/.npmrc; \
( cd .consumers/verdaccio && npm init -y >/dev/null && npm install @local/widget \
&& node -e 'console.log(require("@local/widget")())' )
demo-gitea-npm: ## Publish + install an npm package via Gitea
@TOKEN=$$(cat .gitea-token); \
printf '@local:registry=%s/api/packages/dev/npm/\n//localhost:3001/api/packages/dev/npm/:_authToken=%s\n' \
"$(GITEA)" "$$TOKEN" > samples/npm-widget/.npmrc; \
( cd samples/npm-widget && npm publish ); \
rm -rf .consumers/gitea-npm && mkdir -p .consumers/gitea-npm; \
printf '@local:registry=%s/api/packages/dev/npm/\n' "$(GITEA)" > .consumers/gitea-npm/.npmrc; \
( cd .consumers/gitea-npm && npm init -y >/dev/null && npm install @local/widget \
&& node -e 'console.log(require("@local/widget")())' )
demo-gitea-pypi: ## Build + upload + install a Python package via Gitea
@TOKEN=$$(cat .gitea-token); \
( cd samples/py-widget && rm -rf dist && uv build >/dev/null \
&& uvx twine upload --repository-url $(GITEA)/api/packages/dev/pypi \
-u dev -p "$$TOKEN" dist/* ); \
rm -rf .consumers/gitea-pypi && python3 -m venv .consumers/gitea-pypi; \
.consumers/gitea-pypi/bin/pip install -q \
--index-url $(GITEA)/api/packages/dev/pypi/simple/ local-demo-pkg; \
.consumers/gitea-pypi/bin/python -c 'import local_demo_pkg; print(local_demo_pkg.hello())'
demo-pypiserver: ## Build + upload + install a Python package via pypiserver
( cd samples/py-widget && rm -rf dist && uv build >/dev/null \
&& uvx twine upload --repository-url $(PYPI)/ -u '' -p '' dist/* )
rm -rf .consumers/pypiserver && python3 -m venv .consumers/pypiserver
.consumers/pypiserver/bin/pip install -q --index-url $(PYPI)/simple/ local-demo-pkg
.consumers/pypiserver/bin/python -c 'import local_demo_pkg; print(local_demo_pkg.hello())'
verify: up bootstrap-gitea demo-verdaccio demo-gitea-npm demo-gitea-pypi demo-pypiserver ## Bring everything up and run every demo
clean: ## Remove generated tokens, consumers, and build artifacts
rm -rf .consumers .gitea-token samples/npm-widget/.npmrc \
samples/py-widget/dist samples/py-widget/*.egg-info
Detailed breakdown
demo-verdaccioregisters (or logs in) thedevuser with a singlePUTto Verdaccio’s CouchDB-style user endpoint, which returns an auth token with no interactive prompt. It writes a scoped.npmrc, publishes, then installs into a throwaway consumer directory and calls the module to prove the round trip.demo-gitea-npmtargets the same package at Gitea’s/api/packages/dev/npm/endpoint using the bootstrapped token. Note the//localhost:3001/api/packages/dev/npm/:_authToken=line: npm scopes auth to a URL prefix, so it must match the registry path exactly.demo-gitea-pypianddemo-pypiserverbuild the wheel once and upload it withtwineto each backend, then install into a fresh virtualenv. Gitea requires the token; pypiserver, started with-a ., takes empty credentials.verifychains everything so a single command brings the stack up and exercises all four registries.
Run it
Bring the stack up and mint the Gitea token:
make up
make bootstrap-gitea
Waiting for Gitea at http://localhost:3001 ...
Creating admin user 'dev' (no-op if it exists) ...
New user 'dev' has been successfully created!
Minting a write:package / read:package token ...
Token written to .gitea-token
Verdaccio (npm):
make demo-verdaccio
+ @local/widget@1.0.0
added 1 package, and audited 2 packages in 305ms
widget served from a local registry
Gitea (npm):
make demo-gitea-npm
+ @local/widget@1.0.0
added 1 package, and audited 2 packages in 268ms
widget served from a local registry
Gitea (Python) and pypiserver (Python):
make demo-gitea-pypi
make demo-pypiserver
Successfully built dist/local_demo_pkg-1.0.0.tar.gz
Successfully built dist/local_demo_pkg-1.0.0-py3-none-any.whl
Uploading distributions to http://localhost:3001/api/packages/dev/pypi
python package served from a local registry
...
python package served from a local registry
Each demo publishes a package to a registry and installs it back from a clean consumer, printing the module’s return value. Gitea served both an npm package and a Python package from one container.
Picking one
- Only npm? Verdaccio. It is the lightest real npm registry and proxies npmjs.com so it caches too.
- npm and Python (and maybe more) on a laptop? Gitea or Forgejo. One small container, a real user system, ~12 package formats.
- A team registry with the best UI and a machine that can spare 4 GB+? Nexus Repository OSS.
- Only Python, kept simple? pypiserver. Only Python, with caching and staging? devpi.
- Already running GitLab? Use its built-in registry. Otherwise skip it.
- Wanted free self-hosted npm/PyPI from Artifactory? It is not there in OSS; choose one of the above instead.
Troubleshooting
docker compose upfails with “port is already allocated” on 3000. Another local dev server owns it. The compose file maps Gitea to host 3001 for this reason; change any of4873,3001,8081if they clash, and update the matchingROOT_URL/ registry URL.- npm
publishreturns 401 or 403. The_authTokenline in.npmrcmust match the registry URL prefix exactly, including the trailing path for Gitea (//localhost:3001/api/packages/dev/npm/:_authToken=). A token scoped to the wrong prefix is silently ignored. - Gitea
bootstrapcannot mint a token. Confirm Gitea answers on the host port withcurl http://localhost:3001/api/healthz. If another app answers instead, you have a port collision (see above). pip installcannot find the package. Point at the simple index, not the upload URL:--index-url http://localhost:8081/simple/for pypiserver,.../pypi/simple/for Gitea. The upload and download URLs differ.- A freshly published version does not appear. npm and pip cache. Re-run with
npm install --prefer-onlineorpip install --no-cache-dir.
Recap
- For npm alone, Verdaccio wins on weight and its npmjs.com proxy.
- For npm and Python together on a Mac, Gitea or Forgejo is the best fit: one ~260 MB container hosted both formats in the run above, with room for Cargo, Maven, and more.
- Nexus Repository OSS is the heavyweight to choose when you have the RAM and want the best UI and proxy support; devpi and pypiserver are the Python-only picks; GitLab CE only pays off if you already run it.
- Artifactory OSS does not host npm or PyPI without a paid tier — the one option to rule out for this job.
Next improvements
- Add htpasswd auth to Verdaccio and pypiserver before sharing them beyond localhost; the demo configs are intentionally open.
- Put a registry behind TLS (a reverse proxy such as Caddy) so tokens are not sent in cleartext on a shared network.
- Turn Verdaccio into an offline cache by configuring its uplink and
offlinemode, so CI keeps installing when npmjs.com is unreachable. - Back up the data volumes (
gitea-data,verdaccio-storage) if the hosted packages are not reproducible from source.