How to use a private PyPI index: from install to twine upload and pip install
twine upload returns 401, or pip install says No matching distribution found—usually the package is fine and the private index is not wired: service down, upload URL confused with the install index, username not __token__, or a missing /simple/ suffix.
This article walks through AngusRepo Community self-hosted in order: install → repos and token → .pypirc / pip.conf → minimal-package upload and install → verify. Examples use repo.example.com—swap your domain.
1. Install (get a working console)
Prerequisites: Docker Engine + Compose v2; about 2 CPU / 4 GB; open 80; resolve gm.example.com and repo.example.com (labs can use /etc/hosts). Full steps: First run.
curl --fail --location --progress-bar -o AngusRepo-Community-1.0.0.zip \ https://repo.anguskit.com/raw/raw-public/AngusKit/repo/AngusRepo-Community-1.0.0.zip unzip AngusRepo-Community-1.0.0.zip cd AngusRepo-1.0.0 ./install.sh Wizard aligned with quickstart: Compose; Access → 2; TLS → 4 (HTTP :80); Database → Compose MySQL; Public DNS name → example.com. After Install finished.:
./bin/angusctl.sh doctor # wait for doctor: OK Open http://gm.example.com/, then http://repo.example.com/. For HTTPS see Install & deploy.
The rest assumes HTTP self-hosted. On HTTP, pip usually needs trusted-host; after real TLS, switch to https:// and drop it. Create repos and a token before client config.
2. Repos, token, and topology
twine upload only goes to HOSTED; PROXY and GROUP are read-only. Create:
| Example name | Type | Role |
|---|---|---|
pypi-hosted | HOSTED | twine upload target; disable overwrite |
pypi-proxy | PROXY | Proxy PyPI (https://pypi.org) |
pypi-all | GROUP | Unified pip entry; hosted → proxy |
Keep the two paths distinct:
- Upload:
/pypi/<repo>/(HOSTED root) - Install:
/pypi/<repo>/simple/(PEP 503; trailing/simple/required)
Also prepare:
- [ ] Access token (shown once—save it)
- [ ] CI Write on
pypi-hosted; developers Read onpypi-all - [ ] GROUP order hosted → pypi-proxy
export REPO_TOKEN="your_access_token" export REPO_HOST="repo.example.com" Field detail: PyPI Format.
3. Install: pip.conf / index-url
Default: install via GROUP (pypi-all/simple/), upload via HOSTED.
[global] index-url = http://repo.example.com/pypi/pypi-all/simple/ trusted-host = repo.example.com One-shot:
pip install some-pkg \ --index-url "http://$REPO_HOST/pypi/pypi-all/simple/" \ --trusted-host "$REPO_HOST" Internal-only with public still on PyPI:
pip install your-package \ --index-url https://pypi.org/simple \ --extra-index-url "http://$REPO_HOST/pypi/pypi-hosted/simple/" \ --trusted-host "$REPO_HOST" index-url / extra-index-url must end with /simple/.
4. Publish: .pypirc and twine
username is always __token__; put the access token in password.
[distutils] index-servers = angusrepo [angusrepo] repository = http://repo.example.com/pypi/pypi-hosted/ username = __token__ password = your_access_token python3 -m pip install build twine python3 -m build twine upload \ --repository-url "http://$REPO_HOST/pypi/pypi-hosted/" \ --username __token__ \ --password "$REPO_TOKEN" \ dist/* Or twine upload -r angusrepo dist/*. Same-version re-upload should fail—bump the version.
5. Verify the loop (minimal package)
mkdir -p /tmp/angus-pypi-demo && cd /tmp/angus-pypi-demo cat > pyproject.toml <<'EOF' [build-system] requires = ["setuptools>=61"] build-backend = "setuptools.build_meta" [project] name = "angus-demo-utils" version = "1.0.0" description = "AngusRepo PyPI smoke package" requires-python = ">=3.9" EOF mkdir -p angus_demo_utils echo 'OK = True' > angus_demo_utils/__init__.py python3 -m pip install -q build twine python3 -m build twine upload \ --repository-url "http://$REPO_HOST/pypi/pypi-hosted/" \ --username __token__ \ --password "$REPO_TOKEN" \ dist/* Clean install:
python3 -m venv /tmp/angus-pypi-venv && source /tmp/angus-pypi-venv/bin/activate pip install angus-demo-utils==1.0.0 \ --index-url "http://$REPO_HOST/pypi/pypi-all/simple/" \ --trusted-host "$REPO_HOST" python -c "import angus_demo_utils; print(angus_demo_utils.OK)" Expect True. The console should list 1.0.0.
Minimal CI snippet
- run: | python -m pip install build twine python -m build twine upload \ --repository-url "http://$REPO_HOST/pypi/pypi-hosted/" \ --username __token__ \ --password "$REPO_TOKEN" \ dist/* Poetry: point the primary source at GROUP /simple/ with username __token__. Pipelines: Jenkins build, test, publish.
6. Day-two ops
Cleanup — keep last N on HOSTED; Dry Run first—Cleanup Policies.
Cold PROXY — first public pulls are slow; warm if needed.
Ingest checks — light gate in Repo; deeper scanning elsewhere—admission gates and SBOM.
Error cheat sheet
| Symptom | Likely cause | Fix |
|---|---|---|
| twine 401 | username not __token__, or bad token | Fix credentials; check HOSTED Write |
No matching distribution found | missing /simple/, wrong repo | use …/pypi-all/simple/ |
| Upload OK, install fails | still hitting public PyPI only | fix index-url to GROUP |
| SSL errors on HTTP | no trusted-host | add host in pip.conf |
| 409 / file exists | same version re-upload | bump version |
FAQ
Can I twine to a GROUP?
No. Upload only to HOSTED; pip install via GROUP /simple/.
Can username be my login name?
Per the PyPI token convention, use __token__. A login name usually yields 401.
Is trusted-host required on HTTP?
Yes. Without TLS, pip still expects certificate checks unless you trust the host.
Takeaways
- Install console → HOSTED + PROXY + GROUP → token.
- Upload HOSTED root; install GROUP
/simple/. -
__token__in username; smoke-test build → twine → pip.