Project Setup

Using Private Package Indexes with uv for Internal CLIs

Install and publish internal CLI packages with uv: declare indexes, pin packages to them, authenticate in CI and on laptops, and avoid dependency confusion.

Updated

Internal CLIs live in a mixed world. The tool itself, and a shared acme-auth library it depends on, are published to a company index — Artifactory, Nexus, GitLab's package registry, AWS CodeArtifact, Google Artifact Registry or a plain devpi. Everything else — typer, httpx, rich — comes from PyPI. Getting that mix right matters more than it looks. Configure it loosely and you invite dependency confusion: an attacker registers acme-auth on public PyPI with a higher version number, and a naive resolver happily installs it on every developer laptop. Configure it awkwardly and every new hire spends a morning fighting credentials. This guide sets up uv so that internal packages come only from the internal index, public ones from PyPI, credentials stay out of files, and publishing works from CI. It belongs to the uv topic.

Prerequisites

How uv chooses an index

uv's default index strategy is first-index: for each package name, uv uses the first configured index that has any version of it and ignores all others. That single rule defeats the classic attack. If acme-auth is found on your internal index, a copy on PyPI — whatever its version — is never considered.

Where does acme-auth come from? How different index configurations resolve an internal package name that an attacker has also registered on public PyPI. Where does acme-auth come from? Configuration Picks Safe? pip --extra-index-url highest version anywhere no — confusable uv first-index (default) first index that has it yes, if ordered right explicit index + source pin only the internal index yes, by construction uv unsafe-best-match highest version anywhere no An explicit index with pinned sources does not depend on ordering at all.

But "first" depends on order, and an internal index that merely lists public packages too (a proxy) behaves differently from one that only hosts your own. The robust setup does not rely on ordering at all: it marks the internal index as explicit, so it is used only for packages pinned to it, and pins each internal package by name.

The recipe

Declare the index and pin packages to it

# pyproject.toml
[project]
name = "acme-deploy"
version = "2.4.0"
dependencies = [
    "typer>=0.12",
    "httpx>=0.27,<1",
    "acme-auth>=3.1",            # internal
]

[[tool.uv.index]]
name = "acme"
url = "https://pkgs.acme.example/simple/"
explicit = true                  # only for packages pinned below

[tool.uv.sources]
acme-auth = { index = "acme" }

With explicit = true, uv resolves acme-auth exclusively from the acme index and everything else exclusively from PyPI. A package called acme-auth on PyPI can never be selected, and an outage of the internal index cannot affect public packages. The lock file records which index each package came from, so CI and every laptop agree.

If your index is a proxy that mirrors PyPI as well — common with Artifactory and Nexus — you can instead make it the default, so all traffic goes through it:

[[tool.uv.index]]
name = "acme-proxy"
url = "https://pkgs.acme.example/api/pypi/all/simple/"
default = true                   # replaces PyPI entirely

Choose one model per project. Mixing a default proxy with extra fallback indexes reintroduces ordering questions.

Authenticate without writing secrets to files

Never put credentials in pyproject.toml or the index URL; both end up in git and in the lock file. uv reads them from environment variables named after the index:

export UV_INDEX_ACME_USERNAME=deploy-bot
export UV_INDEX_ACME_PASSWORD="$ACME_TOKEN"     # from your secret store
uv sync --locked

The variable name is the index name, upper-cased, with dashes turned into underscores. On laptops, a credential helper is friendlier than exported variables: uv can call the keyring tool (uv sync --keyring-provider subprocess, or keyring-provider = "subprocess" under [tool.uv]), and keyring backends exist for the major cloud registries. The same pattern for user credentials in your own CLI is covered in storing tokens with keyring.

Getting credentials to uv Ways to supply private index credentials to uv on laptops, in CI and in container builds. Getting credentials to uv Where Use Never Laptop keyring helper tokens in pyproject.toml CI UV_INDEX_ACME_* from secrets tokens in the index URL Docker build --mount=type=secret ARG or ENV A credential that reaches pyproject.toml or uv.lock reaches git.

Installing the internal CLI as a tool

Colleagues install the CLI itself with uv tool install, pointing at the internal index for the tool and its internal dependencies:

uv tool install acme-deploy \
    --index acme=https://pkgs.acme.example/simple/ \
    --index-strategy first-index

Because the tool's own [tool.uv.sources] are not used when installing a published package, users need the index on the command line or in their user-level uv.toml (~/.config/uv/uv.toml), which is a good thing to hand out in an onboarding snippet. Prefer a proxy index here if one exists — then users need only one URL.

Installing an internal CLI Terminal session installing an internal command line tool with uv tool install from a private index using environment variable credentials. Installing an internal CLI bash $ export UV_INDEX_ACME_USERNAME=ann UV_INDEX_ACME_PASSWORD=$(acme-token) $ uv tool install acme-deploy --index acme=https://pkgs.acme.example/simple/ Resolved 19 packages in 412ms Installed 1 executable: acme-deploy $ acme-deploy --version acme-deploy 2.4.0 The index name in --index acme=… is what the variable names are derived from.

Publishing to the internal index

Add a publish URL to the index definition and publish by name:

[[tool.uv.index]]
name = "acme"
url = "https://pkgs.acme.example/simple/"
publish-url = "https://pkgs.acme.example/legacy/"
explicit = true
uv build
UV_PUBLISH_USERNAME=release-bot UV_PUBLISH_PASSWORD="$ACME_TOKEN" uv publish --index acme

Many internal registries now support OIDC as well, which removes the long-lived token from CI just as trusted publishing does for PyPI; check your registry's documentation. The general release flow is in building and publishing a CLI with uv.

Containers and CI caches

Two places need extra care with credentials. In Docker builds, pass index credentials as build secrets (RUN --mount=type=secret,id=acme_token ...) rather than ARG or ENV, which are stored in image layers and visible to anyone who pulls the image. A short shell line can read the secret file into UV_INDEX_ACME_PASSWORD for the single uv sync command that needs it. In CI caches, uv's cache contains downloaded wheels but never credentials, so caching it as described in caching uv dependencies in CI is safe — but scope the cache key to the lock file, so a cache populated by a job with access to the internal index is not restored into a public fork's job that should not have those internal wheels.

The lock file deserves one more check: uv records the index URL for each internal package. If the URL contains a token — some registries hand out URLs like https://user:token@host/simple/ — it ends up in uv.lock and in git. Always configure the bare URL in pyproject.toml and supply credentials separately, and treat a credential found in a lock file as leaked.

UX considerations

  • Reserve your names on PyPI. Registering empty placeholder projects for internal package names is a cheap extra defence against confusion attacks on tools that do not use first-index, such as plain pip with --extra-index-url.
  • Give a one-paragraph onboarding snippet: the index URL, the credential variable names and the uv tool install command. Most credential failures are first-day failures.
  • Make auth errors understandable. A 401 from the index surfaces as "failed to fetch"; document the most common causes — missing variables, expired tokens, VPN off — next to the snippet.
  • Keep the index name stable. The environment variable names derive from it, so renaming the index breaks every CI secret mapping.
  • Prefer a proxy for users, explicit pins for projects. One URL is easiest for people installing; explicit pins are safest for projects resolving dependencies.

Testing the behaviour

You can prove the explicit-index behaviour locally with a "flat" index — a directory of wheels — standing in for the private server:

uv build --out-dir /tmp/fake-index          # in the acme-auth project
# in the consumer project
[[tool.uv.index]]
name = "acme"
url = "/tmp/fake-index"
format = "flat"
explicit = true

[tool.uv.sources]
acme-auth = { index = "acme" }
uv lock && grep -A2 'name = "acme-auth"' uv.lock

The lock entry shows the package's source as the local index. Then add a dependency that is not pinned and confirm it still resolves from PyPI. In CI, add a check that every internal package name in pyproject.toml has a [tool.uv.sources] entry — a short script that reads both tables with tomllib — so nobody adds an internal dependency that silently falls back to public resolution:

# scripts/check_internal_sources.py
import re
import sys
import tomllib
from pathlib import Path

INTERNAL_PREFIXES = ("acme-",)

data = tomllib.loads(Path("pyproject.toml").read_text())
deps = data["project"].get("dependencies", [])
names = {re.match(r"[A-Za-z0-9._-]+", d.strip()).group(0).lower() for d in deps}
sources = {k.lower() for k in data.get("tool", {}).get("uv", {}).get("sources", {})}
missing = sorted(n for n in names if n.startswith(INTERNAL_PREFIXES) and n not in sources)
if missing:
    print(f"internal packages without an index pin: {', '.join(missing)}", file=sys.stderr)
    sys.exit(1)
print("all internal packages pinned to an index")

Conclusion

uv makes private indexes safe by default with its first-index strategy, and safer still when you declare the internal index as explicit and pin internal packages to it through [tool.uv.sources]. Keep credentials in UV_INDEX_<NAME>_* variables or a keyring helper, publish with uv publish --index, give users a single onboarding snippet, and add a CI check that no internal dependency escapes its pin.

Frequently asked questions

Is --extra-index-url with pip still safe?

Not on its own: pip treats all indexes as equal and picks the highest version anywhere, which is exactly what dependency confusion exploits. If some users must use pip, have them use a proxy index that serves both internal and public packages as their only index.

What does unsafe-best-match do?

It makes uv behave like pip — considering every version on every index and picking the best. The name is a warning. Use it only for a specific, understood migration problem, never as a default.

Can the lock file be used by people without access to the internal index?

No; resolving or syncing needs the index for internal packages. For open-source contributors, keep internal dependencies optional, or provide a public build that does not need them.

How do I use AWS CodeArtifact or Google Artifact Registry?

Both issue short-lived tokens. Fetch a token in CI (aws codeartifact get-authorization-token or gcloud auth print-access-token), export it as UV_INDEX_<NAME>_PASSWORD with the documented username, and run uv. On laptops, the vendors' keyring plugins let --keyring-provider subprocess fetch tokens automatically.