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 run to a successful publish.
  • 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

RegistryFormatsSetupIdle RAMAuthWeb UIUpstream proxyBest for
Verdaccionpm (first-class)trivial~70 MBtokens, htpasswdyesyes (npmjs)npm, solo or small team
Gitea / Forgejonpm, PyPI, +10 moreeasy~116 MBtokens, full usersyesnomulti-format on a laptop
Nexus Repository OSSnpm, PyPI, +manymoderatemultiple GBfull RBACexcellentyesmulti-format with RAM to spare
devpiPyPI onlymoderatemoderateusers, indexesbasicyes (PyPI)a serious Python shop
pypiserverPyPI onlytrivial~19 MBhtpasswdnonenothe simplest private PyPI
GitLab CEnpm, PyPI, +manyheavymultiple GBfull RBACexcellentlimitedonly if you already run GitLab
Artifactory OSSMaven family onlymoderatemultiple GBfull RBACgoodMaven onlyJVM shops (not npm/PyPI)
Static PEP 503 dirPyPI (read-only)trivial~0nonenonenoa 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.

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 version should print a server version).
  • Node.js 20+ and npmbrew install node; the demos publish and install npm packages.
  • uv 0.5+brew install uv; used to build the Python package and to run twine via uvx.
  • Xcode Command Line Tools (xcode-select --install) for make.

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

  • .gitignore comes first and blocks the two things this project generates that must never be committed: .gitea-token (a live credential) and the per-package .npmrc files 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=true skips it, and ROOT_URL is set to http://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 -v wipes 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/widget is 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-pkg is a standard uv_build Python 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 create runs inside the container over docker 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:package and read:package scopes, the least privilege needed to publish and install. pkg-$RANDOM keeps the token name unique so repeated runs do not collide.
  • The token lands in .gitea-token, which .gitignore already 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-verdaccio registers (or logs in) the dev user with a single PUT to 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-npm targets 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-pypi and demo-pypiserver build the wheel once and upload it with twine to each backend, then install into a fresh virtualenv. Gitea requires the token; pypiserver, started with -a ., takes empty credentials.
  • verify chains 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 up fails 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 of 4873, 3001, 8081 if they clash, and update the matching ROOT_URL / registry URL.
  • npm publish returns 401 or 403. The _authToken line in .npmrc must 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 bootstrap cannot mint a token. Confirm Gitea answers on the host port with curl http://localhost:3001/api/healthz. If another app answers instead, you have a port collision (see above).
  • pip install cannot 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-online or pip 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 offline mode, 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.