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.orgfrom 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-webuv 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/activateInitialize a server data directory once:
devpi-init --serverdir ~/devpi-dataStart the server. By default it binds to localhost:3141:
devpi-server --serverdir ~/devpi-data \
--host 127.0.0.1 --port 3141The 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=secretdevpi 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/devbases=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.gzUpload to the active index:
devpi upload --from-dir distDevpi 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-greeterFor 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 = trueSetting 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-greeterTo 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/devdevpi 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 ~/packagesDrop 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/*.whluv 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.confA 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
pandasThe 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/webThat 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 = trueLearn more
- devpi documentation covers replication, staging indexes, and tox integration
- pypiserver README covers htpasswd setup and reverse-proxy configuration
- bandersnatch documentation covers PEP 381 (the original PyPI mirror protocol), diff-file workflows, and filter plugins
- packaging.python.org: Hosting your own simple repository lists every known hosting tool
- How to use private package indexes with uv covers client-side configuration for any PEP 503 index, including managed registries
- What is PyPI? explains the protocol that every self-hosted index implements
- What is PEP 503? describes the simple repository API that uv and pip speak