Skip to content

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-server

Create 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-data

htpasswd 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 alice

Either way the file holds one hashed line per user:

alice:$apr1$l5ehbNnm$2rdDeg9AFN4P6i8xpjU9a1

Run 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 packages
Bottle 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):

pyproject.toml
[[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 internal
Successfully 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.

pyproject.toml
[[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-greeter
Resolved 2 packages in 3ms
Prepared 1 package in 2ms
Installed 1 package in 0.87ms
 + internal-greeter==0.1.0

Without 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:

pip.conf
[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.

Last updated on