How to Host Your Own Python Package Index
A self-hosted Python package index gives a team somewhere to put internal_greeter-0.1.0-py3-none-any.whl without publishing it to PyPI. This guide runs pypiserver end to end: install, password file, server, uv publish, and install with uv or pip.
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
Self-hosting means running a server, renewing TLS, and testing backups; managed registries such as Artifactory, CodeArtifact, and Cloudsmith do that for a per-seat or per-GB fee. Self-host when the build network cannot reach pypi.org, when the registry bill outweighs the ops time, or when a team already runs production infrastructure and wants a fixed cost.
Choose a self-hosted index
| Tool | What it does | Pick it when |
|---|---|---|
| pypiserver | Serves wheels and sdists from a directory; accepts uploads; redirects to PyPI for everything else | One team needs to host internal packages |
| devpi | Per-user indexes that inherit from a local PyPI cache, with a web UI and staging workflows | Several teams need separate indexes or a PyPI cache in the same process |
| bandersnatch | Syncs all or part of PyPI to local disk for a static file server | The network is airgapped, or every dependency must be available offline |
Install pypiserver
Install pypiserver as a uv tool with the passlib extra, which the password file needs:
uv tool install "pypiserver[passlib]" + passlib==1.7.4
+ pip==26.2.1
+ pypiserver==2.4.1
Installed 1 executable: pypi-serverCreate the password file
pypiserver reads an Apache htpasswd file. Work from one directory so every path in this guide is relative:
mkdir pypi-data
cd pypi-datahtpasswd ships with Apache and is preinstalled on macOS and most Linux distributions (package apache2-utils on Debian and Ubuntu). The command prompts for the password twice:
htpasswd -c .htpasswd aliceEither way the file holds one hashed line per user:
alice:$apr1$l5ehbNnm$2rdDeg9AFN4P6i8xpjU9a1Run the server
Create a directory for the packages and start pypiserver pointing at it and at the password file:
mkdir packages
pypi-server run -P .htpasswd -a update,download packagesBottle v0.12.25 server starting up (using AutoServer(handler_class=<class 'pypiserver.__main__.WsgiHandler'>))...
Listening on http://0.0.0.0:8080/
Hit Ctrl-C to quit.-a update,download requires credentials for uploads and file downloads; the default is update only, which leaves downloads anonymous. The index is at http://127.0.0.1:8080/simple/.
For a package name that is not in packages/, pypiserver redirects the client to https://pypi.org/simple/<name>/, so public dependencies keep resolving through it. Pass --disable-fallback to serve local packages only.
Important
pypiserver listens on every interface with no TLS. Keep it on localhost or put it behind Nginx or Caddy with TLS before exposing it to a network, because the credentials travel as HTTP Basic auth.
Publish a package with uv
In the package’s pyproject.toml, declare the index with a publish-url (pypiserver accepts uploads at its root URL):
[[tool.uv.index]]
name = "internal"
url = "http://127.0.0.1:8080/simple/"
publish-url = "http://127.0.0.1:8080/"Build the distributions, then set the credentials and publish by index name:
uv build
export UV_PUBLISH_USERNAME=alice
export UV_PUBLISH_PASSWORD=secret
uv publish --index internalSuccessfully built dist/internal_greeter-0.1.0.tar.gz
Successfully built dist/internal_greeter-0.1.0-py3-none-any.whl
Publishing 2 files to http://127.0.0.1:8080/
Hashing internal_greeter-0.1.0-py3-none-any.whl (1.5KiB)
Uploading internal_greeter-0.1.0-py3-none-any.whl (1.5KiB)
Hashing internal_greeter-0.1.0.tar.gz (694.0B)
Uploading internal_greeter-0.1.0.tar.gz (694.0B)Running uv publish --index internal a second time prints File internal_greeter-0.1.0-py3-none-any.whl already exists, skipping for each file. Bump the version to publish again.
Install from the index with uv
In the consuming project, declare the same index without publish-url. Add default = true to replace PyPI entirely; leave it out and uv checks the internal index first and PyPI second.
[[tool.uv.index]]
name = "internal"
url = "http://127.0.0.1:8080/simple/"uv reads credentials for a named index from UV_INDEX_<NAME>_USERNAME and UV_INDEX_<NAME>_PASSWORD, with the name uppercased:
export UV_INDEX_INTERNAL_USERNAME=alice
export UV_INDEX_INTERNAL_PASSWORD=secret
uv add internal-greeterResolved 2 packages in 3ms
Prepared 1 package in 2ms
Installed 1 package in 0.87ms
+ internal-greeter==0.1.0Without the credentials, the index page still loads (only download is authenticated) and the install fails on the file itself with HTTP status client error (401 Unauthorized). uv pip install internal-greeter in the same directory uses the same index and credentials. For tool.uv.sources pinning and other index options, see How to use private package indexes with uv.
Install from the index with pip
pip takes the index URL and credentials from its user-level config file, ~/.config/pip/pip.conf on macOS and Linux or %APPDATA%\pip\pip.ini on Windows:
[global]
index-url = http://alice:secret@127.0.0.1:8080/simple/pip install internal-greeter then resolves through pypiserver, and public packages follow the redirect to PyPI.
Mirror PyPI for offline use with bandersnatch
For an airgapped network, bandersnatch syncs PyPI to local disk and any static file server serves the result. A full mirror is 46 TB across roughly 890,000 projects, so restrict it with the allowlist_project plugin in bandersnatch.conf. Install it with uv tool install bandersnatch, then run bandersnatch --config /etc/bandersnatch.conf mirror (the --config flag goes before the subcommand) on a cron or systemd timer, and point uv at the served simple/ directory with [[tool.uv.index]] as shown for pypiserver.