# 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](https://pydevtools.com/handbook/explanation/what-is-pypi.md). This guide runs [pypiserver](https://github.com/pypiserver/pypiserver) end to end: install, password file, server, `uv publish`, and install with [uv](https://pydevtools.com/handbook/reference/uv.md) or [pip](https://pydevtools.com/handbook/reference/pip.md).

Hosting an index is the server-side counterpart to [How to use private package indexes with uv](https://pydevtools.com/handbook/how-to/how-to-use-private-package-indexes-with-uv.md), 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:

```bash
uv tool install "pypiserver[passlib]"
```

```text
 + 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:

```bash
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:

```bash
htpasswd -c .htpasswd alice
```
`htpasswd` ships with Apache, which Windows lacks by default. Write the same file with passlib instead:

```powershell
uvx --from passlib python -c "from passlib.apache import HtpasswdFile; ht = HtpasswdFile('.htpasswd', new=True); ht.set_password('alice', 'secret'); ht.save()"
```
Either way the file holds one hashed line per user:

```text
alice:$apr1$l5ehbNnm$2rdDeg9AFN4P6i8xpjU9a1
```

## Run the server

Create a directory for the packages and start pypiserver pointing at it and at the password file:

```bash
mkdir packages
pypi-server run -P .htpasswd -a update,download packages
```

```text
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):

```toml {filename="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:

```bash
uv build
export UV_PUBLISH_USERNAME=alice
export UV_PUBLISH_PASSWORD=secret
uv publish --index internal
```
```powershell
uv build
$env:UV_PUBLISH_USERNAME = "alice"
$env:UV_PUBLISH_PASSWORD = "secret"
uv publish --index internal
```
```text
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.

```toml {filename="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:

```bash
export UV_INDEX_INTERNAL_USERNAME=alice
export UV_INDEX_INTERNAL_PASSWORD=secret
uv add internal-greeter
```
```powershell
$env:UV_INDEX_INTERNAL_USERNAME = "alice"
$env:UV_INDEX_INTERNAL_PASSWORD = "secret"
uv add internal-greeter
```
```text
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](https://pydevtools.com/handbook/how-to/how-to-use-private-package-indexes-with-uv.md).

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

```ini {filename="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](https://bandersnatch.readthedocs.io/) 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.
