Skip to content

How to Host Your Own Python Package Index

A self-hosted Python package index can save a team from PyPI outages, vendor lock-in, or restricted networks, but it also gives that team another service to run. Three tools cover almost every use case: devpi for production multi-team setups, pypiserver for a small team that needs somewhere to put internal-greeter-0.1.0.whl, and bandersnatch for full PyPI mirrors on networks that cannot reach pypi.org.

Hosting an index is the server-side counterpart to How to use private package indexes with uv, which covers the client configuration for managed services like AWS CodeArtifact and JFrog Artifactory.

Decide whether to self-host

Hosting an index has an operational tax: a server to keep alive, TLS to renew, storage to monitor, auth to wire to company SSO, backups to test. SaaS registries handle that. Pick self-hosted only when at least one of these applies.

  • Airgapped or restricted network. No outbound HTTPS to pypi.org from the build environment. A bandersnatch mirror or devpi mirror index is the only way to install anything.
  • Cost or vendor avoidance. Artifactory, CodeArtifact, and Cloudsmith bill per seat or per GB. A devpi container on existing infrastructure is a fixed cost.
  • PyPI hardening. Surviving a PyPI outage, controlling exactly which package versions reach builds, or scanning every dependency before it lands in CI.
  • Existing ops capacity. A team that already runs production infrastructure can host devpi cheaply. A team without on-call rotation should buy.

If none of those apply, use a managed registry and skip the rest of this guide.

Choose the right self-hosted package index

Each tool solves a different problem. The names are similar enough that they get confused in older blog posts.

Tool What it does Pick it when
devpi Per-user and per-team indexes that inherit from PyPI, plus a write API for uploads Multiple teams need separate indexes, or staging-then-promote workflows, or PyPI caching plus internal hosting in one process
pypiserver Serves wheels and sdists from a directory; accepts uploads One team, internal packages only, “make this go away” energy
bandersnatch Full PyPI clone synced over the PEP 691 JSON simple-index API Airgapped network, or an organization that needs the entire index available offline
proxpi Read-through caching proxy CI cache for a public PyPI mirror with no internal packages

devpi covers most of pypiserver’s use cases plus a lot more. bandersnatch and proxpi serve different audiences: bandersnatch ships every package on PyPI (~14TB and growing), proxpi caches only what someone has installed.

Note

packaging.python.org’s hosting guide lists more options including simpleindex, dumb-pypi, and Pulp-python. Those exist but have small userbases. devpi, pypiserver, and bandersnatch cover the typical cases.

The rest of this guide walks through devpi end to end, then covers pypiserver and bandersnatch as alternatives.

Run a devpi server

devpi-server 6.19 needs Python 3.9 or newer. Install the three packages into a dedicated virtual environment using uv:

uv venv --python 3.12 ~/devpi-env
uv pip install --python ~/devpi-env/bin/python \
  devpi-server devpi-client devpi-web

uv pip install --python <path> writes packages into the target environment without requiring pip itself to be installed there. uv venv does not seed pip by default; this is the canonical way around it.

devpi-server is the index. devpi-client is the CLI for creating users and uploading packages. devpi-web adds a browsable web UI and search; it is optional but small.

Activate the environment so the rest of this guide can call devpi, devpi-init, and devpi-server without absolute paths:

source ~/devpi-env/bin/activate

Initialize a server data directory once:

devpi-init --serverdir ~/devpi-data

Start the server. By default it binds to localhost:3141:

devpi-server --serverdir ~/devpi-data \
  --host 127.0.0.1 --port 3141

The first request to the index triggers a one-time index of every PyPI project name; expect ~30 seconds of CPU work on first start.

Important

The --host 127.0.0.1 flag keeps the server on localhost. To expose it on a LAN, change to --host 0.0.0.0 only after putting it behind a reverse proxy with TLS. Devpi’s HTTP server has no built-in TLS support and the upload endpoint accepts authenticated package uploads.

Note

This guide runs devpi as a localhost foreground process for demonstration. A production setup adds process supervision (systemd, supervisord, or a container orchestrator), TLS termination via Nginx or Caddy, persistent and snapshotted storage for --serverdir, and external authentication via devpi-ldap or a reverse-proxy auth header. The devpi quickstart and the oneuptime Docker walkthrough cover the production pieces.

Create a user and an index on devpi

Open a second terminal (the server keeps running in the first) and activate the same environment. Then connect the client to the running server and create a user account:

devpi use http://127.0.0.1:3141
devpi user -c alice password=secret
devpi login alice --password=secret

devpi user -c creates the account; devpi login exchanges the password for a token cached at ~/.devpi/client/. The token expires after 10 hours by default.

Create an index that inherits from root/pypi, the built-in PyPI mirror:

devpi index -c dev bases=root/pypi
devpi use alice/dev

bases=root/pypi is the part that turns a vanilla index into a PyPI-passthrough cache. A package not found on alice/dev falls through to root/pypi, which fetches it from pypi.org and caches it locally. Subsequent installs hit the cache.

Upload an internal package to devpi

Build a wheel with uv build:

cd internal-greeter
uv build
ls dist/
# internal_greeter-0.1.0-py3-none-any.whl
# internal_greeter-0.1.0.tar.gz

Upload to the active index:

devpi upload --from-dir dist

Devpi prints file_upload of internal_greeter-0.1.0-...whl to http://127.0.0.1:3141/alice/dev/. The package is now available on the simple-index endpoint:

curl http://127.0.0.1:3141/alice/dev/+simple/internal-greeter/

The response is a PEP 503 HTML page listing every uploaded version. Installers use the +simple/ URL, not the human-facing project page.

Install from devpi with uv

Point uv at the devpi simple index. The simplest form sets the index for a single command:

uv pip install \
  --index-url http://127.0.0.1:3141/alice/dev/+simple/ \
  internal-greeter

For a project that uses devpi as its primary registry, declare it in pyproject.toml:

[[tool.uv.index]]
name = "devpi"
url = "http://127.0.0.1:3141/alice/dev/+simple/"
default = true

Setting default = true replaces PyPI entirely. Because the alice/dev index inherits from root/pypi, public packages still resolve through devpi’s cache. Public packages requested for the first time take a roundtrip to PyPI. Repeat installs are cached.

On a local M-series Mac, uv pip install requests through devpi took about 2.5s on the first hit and 0.14s from cache. On a fresh Debian x86_64 Modal container, the same test took about 2.0s cold and 0.45s warm. Treat those as ballpark numbers, not a guarantee.

For deeper coverage of [[tool.uv.index]], authentication, and tool.uv.sources pinning, see How to use private package indexes with uv.

Install from devpi with pip

Pip uses the same simple/ URL via the --index-url flag or a pip.conf entry:

pip install \
  --index-url http://127.0.0.1:3141/alice/dev/+simple/ \
  internal-greeter

To make the setting persistent across all pip invocations, write it to the user-level config:

# ~/.config/pip/pip.conf
[global]
index-url = http://127.0.0.1:3141/alice/dev/+simple/

The devpi client can write this for you. From an environment with devpi-client installed and an active index:

devpi use --set-cfg alice/dev

devpi use --set-cfg edits pip.conf and uv.toml to point at the active devpi index. This helps on a developer machine but is risky in CI, where explicit --index-url flags or UV_INDEX environment variables make the configuration auditable.

Run pypiserver as a lightweight alternative

If devpi feels heavy and the only requirement is “a place to host internal wheels,” pypiserver 2.4 needs a directory and a process. Install into a fresh environment, activate it, then run:

uv venv --python 3.12 ~/pypiserver-env
uv pip install --python ~/pypiserver-env/bin/python pypiserver
source ~/pypiserver-env/bin/activate   # PowerShell: & $HOME\pypiserver-env\Scripts\Activate.ps1
mkdir ~/packages
pypi-server run --port 8080 ~/packages

Drop wheels into ~/packages and they appear at http://127.0.0.1:8080/simple/. The default mode allows anonymous downloads and disables uploads. To enable authenticated uploads:

htpasswd -sc ~/.htpasswd alice           # creates an htpasswd file
pypi-server run --port 8080 \
  -P ~/.htpasswd -a update,download \
  ~/packages

-P points to the credentials file; -a update,download requires authentication for both actions. Upload with twine:

twine upload \
  --repository-url http://127.0.0.1:8080/ \
  --username alice --password secret \
  dist/*.whl

uv installs from a pypiserver index the same way it installs from devpi, by pointing --index-url at /simple/.

Tip

Pypiserver does not cache PyPI. If a package is not in ~/packages, the install fails. To layer pypiserver on top of PyPI, use --extra-index-url https://pypi.org/simple/ on the client side or run pypiserver in --fallback-url mode.

Mirror PyPI for offline use with bandersnatch

bandersnatch is the PyPA-maintained tool for syncing every package on PyPI to local disk. Used by airgapped enterprises and clusters with strict egress rules.

A full mirror is large: as of April 2026, PyPI hosts roughly 800,000 projects and 14TB of distribution files. Plan storage accordingly, or use bandersnatch’s filtering to mirror only a subset.

Install and write a config:

uv venv --python 3.12 ~/bandersnatch-env
uv pip install --python ~/bandersnatch-env/bin/python bandersnatch
source ~/bandersnatch-env/bin/activate   # PowerShell: & $HOME\bandersnatch-env\Scripts\Activate.ps1
bandersnatch mirror --config /etc/bandersnatch.conf

A minimal bandersnatch.conf:

[mirror]
directory = /srv/pypi-mirror
master = https://pypi.org
workers = 3
hash-index = false
stop-on-error = false

[plugins]
enabled =
    allowlist_project

[allowlist]
packages =
    requests
    numpy
    pandas

The allowlist_project plugin restricts the mirror to listed packages and their dependencies. Without a plugin block, bandersnatch syncs everything on PyPI.

Run the sync regularly via cron or a systemd timer. After the first run, expose the mirror with any static-file web server:

python -m http.server 9000 --directory /srv/pypi-mirror/web

That is fine for a smoke test; in production, use Nginx or Caddy with TLS.

For airgapped deployment, sync to local disk on a connected machine, copy the mirror over a one-way diode or sneakernet, and serve the copy on the airgapped network. Bandersnatch’s diff file output supports incremental transfers.

Point uv at the mirror just like any other simple index:

[[tool.uv.index]]
name = "internal-mirror"
url = "https://mirror.internal.example.com/simple/"
default = true

Learn more

Last updated on

Please submit corrections and feedback...