Every serious Python team eventually hits the same wall: you need to share code internally without publishing it to PyPI. Maybe it’s proprietary business logic, internal utilities, or packages that simply aren’t ready for public consumption. That’s when you need a private Python repository.
This guide covers everything: why you need one, how the options compare, and step-by-step configuration for pip, Poetry, and uv. By the end, you’ll know exactly which solution fits your team and how to set it up.
Why Teams Need Private Python Repositories
Before diving into solutions, let’s understand the problems private repositories solve.
1. Proprietary Code Protection
Your competitive advantage lives in your code. Publishing internal libraries to public PyPI exposes your intellectual property to competitors. A private repository keeps your algorithms, business logic, and internal tooling secure.
# This shouldn't be on public PyPIfrom acme_ml_core import PropietaryPredictionModelfrom acme_auth import InternalSSOClientfrom acme_data import CompanyDataPipeline2. Version Control Across Teams
When multiple teams depend on shared libraries, version chaos ensues without central management:
# Without a private repo, teams end up with:team-a/requirements.txt: git+ssh://github.com/company/shared-lib.git@v1.2.3team-b/requirements.txt: git+ssh://github.com/company/shared-lib.git@mainteam-c/requirements.txt: /network/share/shared-lib-1.1.0.whl
# With a private repo, everyone uses:requirements.txt: acme-shared-lib==1.2.33. CI/CD Pipeline Reliability
Git-based dependencies create fragile pipelines. Network issues, repository access changes, or branch deletions break builds unpredictably. A private repository provides stable, versioned artifacts that won’t disappear.
4. Compliance and Audit Requirements
Regulated industries need artifact traceability. Who published what, when, and which version is running in production? Private repositories provide audit logs, access controls, and retention policies that Git repositories lack.
5. Build Performance
Pulling from Git requires cloning entire repositories. A private PyPI repository serves pre-built wheels, dramatically reducing install times:
# Git dependency: Clone repo, install dependencies, build wheelpip install git+https://github.com/company/big-repo.git# Time: 45-120 seconds
# Private repository: Download pre-built wheelpip install acme-big-package --index-url https://repo.company.com/simple# Time: 2-5 secondsTip
Teams that switch from Git-based dependencies to private repositories often see CI/CD pipeline times drop significantly, with the added benefit of reproducible builds.
Comparing Private Python Repository Options
Let’s examine the main options: self-hosted PyPI servers, enterprise solutions (Artifactory and Nexus), and managed services like CloudRepo.
Option 1: Self-Hosted PyPI Server
Tools like pypiserver or devpi let you run your own PyPI-compatible server.
Pros:
- Full control over infrastructure
- No external dependencies
- Low direct costs (just server expenses)
Cons:
- Significant setup and maintenance burden
- Security patching is your responsibility
- No built-in high availability
- Limited enterprise features (RBAC, audit logs, SSO)
Best for: Teams with dedicated DevOps capacity who need air-gapped environments or have unusual customization requirements.
Option 2: JFrog Artifactory
Artifactory is the enterprise incumbent, supporting multiple package formats including Python.
Pros:
- Mature enterprise features
- Multi-format support (Maven, npm, Docker, PyPI)
- Strong security scanning with Xray
- Virtual repositories for caching public PyPI
Cons:
- Consumption-based pricing creates unpredictable bills
- Complexity often exceeds requirements
- Community support on the Pro plan; 24/7 SLA support starts at Enterprise X
- Steep learning curve
Pricing reality: Artifactory’s Pro base is $150/month at list. With their consumption model (storage + transfer combined), a team with 100GB storage and 450GB of monthly transfer pays $806.25/month at list; the worked example below shows the math.
Option 3: Sonatype Nexus
Nexus Repository Manager is another enterprise option, originally focused on Maven but now supporting Python.
Pros:
- Self-hosted option available
- Multi-format support
- Proxy caching for public repositories
Cons:
- Python support less mature than Maven
- Similar consumption-based pricing to Artifactory
- Complex administration
- Enterprise features require expensive tiers
Pricing reality: Nexus cloud starts at $1,950 a year (about $162.50/month) but follows a similar consumption model. Heavy CI/CD usage triggers substantial overage charges.
Option 4: CloudRepo
CloudRepo is a managed repository service built for simplicity and predictable pricing.
Pros:
- Predictable monthly pricing
- Support included (real engineers, not call centers)
- Simple setup in minutes
- Native support for pip, Poetry, and uv
Cons:
- Fewer advanced features than Artifactory
- No self-hosted option
Pricing: The Team plan at $499/month billed annually, includes 250GB storage with a generous transfer allowance and unlimited users.
Quick Comparison Table
| Feature | Self-Hosted | Artifactory | Nexus | CloudRepo |
|---|---|---|---|---|
| Maintenance | High | Medium | Medium | None |
| pip Support | Yes | Yes | Yes | Yes |
| Poetry Support | Basic | Good | Basic | Native |
| uv Support | Basic | Basic | Basic | Native |
| SSO/SAML | DIY | Enterprise X | Pro | Included |
| Support | None | Community on Pro; 24/7 SLA on Enterprise X | Enterprise support and SLA on Pro | Included |
| Starting Price | $100+/month (infra) | $150/month list + usage, billed monthly | $162.50/month + usage ($1,950/year, billed annually) | $99/month billed annually |
Pricing your whole team for a year? Open the ROI calculator
Important
The “real” cost of Artifactory and Nexus often surprises teams. Consumption fees and enterprise feature tiers add to the advertised price. Always calculate your expected usage before committing.
CloudRepo includes single sign-on and human support from the people who build CloudRepo on every plan.
| Feature | JFrog Pro | CloudRepo, every plan |
|---|---|---|
| Single sign-on (SSO) | Not in Pro: JFrog lists SAML and OAuth sign-on for Enterprise X Enterprise X is for organizations who need multi-site capabilities, uptime and support guarantees, and enterprise access controls (ie. OAuth, SAML, CName). JFrog’s pricing page, archived | Included |
| Support | Community support: public documentation, a knowledge base and Stack Overflow Community Support - Available for Free solutions and Pro subscriptions - JFrog maintains public documentation, knowledge base, and engages in conversations on Stack Overflow. JFrog’s pricing page, archived | Human support from the people who build CloudRepo |
Setting Up pip with a Private Repository
pip is the standard Python package installer. Here’s how to configure it for private repositories.
Basic pip Configuration
pip cannot tie a package to an index. Given your private repository and PyPI together (in
pip.conf, PIP_EXTRA_INDEX_URL or --extra-index-url), it reads them as one and installs the
highest version it finds in either, so a package someone publishes on PyPI under your private
package’s name can be installed instead of yours. Leave pip’s default index at PyPI for public
packages, and install private packages in a step of their own with CloudRepo as the only index:
# Public packages, from PyPIpip install -r requirements.txt
# Private packages, from CloudRepo alone (the file names its own index)pip install -r requirements-private.txt
# Your own project last, without its dependencies (both steps above installed them)pip install --no-deps -e .Nothing the first step installs may depend on a private package, your own project included: pip would fetch that private package from PyPI in that step.
Warning
Do not add trusted-host for your CloudRepo host. pip documents --trusted-host as
marking a host trusted “even though it does not have valid or any HTTPS”. It disables
certificate verification for that host, which would send your login over a connection that
accepts any certificate.
CloudRepo serves *.mycloudrepo.io with a publicly trusted certificate, so there is
nothing to work around. If a corporate proxy intercepts TLS with its own CA, trust that
CA instead: bundle it with the public roots and point pip at the bundle with
pip config set global.cert /path/to/corporate-bundle.pem.
Environment Variables for CI/CD
For automated environments, write the ~/.netrc entry from your CI secrets at the start of the job
instead of keeping a config file. pip reads the login for the host from it:
# The credential requirements-private.txt reads, from your CI secrets.# The login is the email address of the account that created the token.(umask 077 && printf 'machine your-org.mycloudrepo.io\nlogin you@example.com\npassword %s\n' \ "$CLOUDREPO_TOKEN" >> ~/.netrc)chmod 600 ~/.netrc
pip install -r requirements.txt # public packages, from PyPIpip install -r requirements-private.txt # private packages, from CloudRepo alonePer-Project Configuration
Keep two requirements files in the project:
# Public packages, from PyPI. List the public packages your private# packages depend on here too: the private step does not look on PyPI.requests>=2.28.0pydantic>=2.0.0--index-url https://your-org.mycloudrepo.io/repositories/python/simpleacme-shared-lib==1.2.0acme-auth==2.0.0The --index-url line makes CloudRepo the only index for that install. pip reads the login for the
host from ~/.netrc, so the file holds no credential and can be committed.
Publishing Packages with twine
To upload packages to your private repository:
# Install twine if you haven't alreadypip install twine
# Build your packagepython -m build
# Upload to CloudRepoexport TWINE_USERNAME=you@example.comexport TWINE_PASSWORD="$CLOUDREPO_TOKEN"twine upload --non-interactive \ --repository-url https://your-org.mycloudrepo.io/repositories/python/ \ dist/*Or configure twine in ~/.pypirc:
[distutils]index-servers = cloudrepo
[cloudrepo]repository = https://your-org.mycloudrepo.io/repositories/python/username = your-usernamepassword = your-api-tokenThen upload with:
twine upload --repository cloudrepo dist/*Setting Up Poetry with a Private Repository
Poetry is the modern standard for Python dependency management. Its private repository support is excellent.
Configuring Poetry Sources
Add your private repository to pyproject.toml:
[tool.poetry]name = "my-project"version = "1.0.0"description = "A project using private packages"authors = ["Your Team <team@company.com>"]
[tool.poetry.dependencies]python = "^3.10"# Public packages - Poetry fetches from PyPI by defaultrequests = "^2.31.0"pydantic = "^2.5.0"# Private packages - each names the source, so Poetry fetches it from CloudRepo aloneacme-shared-lib = { version = "^1.2.0", source = "cloudrepo" }acme-auth = { version = "^2.0.0", source = "cloudrepo" }
# Configure your private repository[[tool.poetry.source]]name = "cloudrepo"url = "https://your-org.mycloudrepo.io/repositories/python/simple"priority = "explicit"
[build-system]requires = ["poetry-core>=1.0.0"]build-backend = "poetry.core.masonry.api"Poetry Source Priorities
Poetry 1.5+ gives each source a priority:
explicitis asked only for the dependencies that name it. Use it for private packages, and name it on every private package, including the private packages your private packages depend on: a dependency that names no source is looked up on PyPI.primaryis asked for every package. Declaring one turns off Poetry’s implicit PyPI, so use it only when every package comes from CloudRepo.supplementalis asked only when no other source has a match. Do not put private packages there: PyPI answers first.
# Two CloudRepo repositories, each explicit[[tool.poetry.source]]name = "cloudrepo-internal"url = "https://your-org.mycloudrepo.io/repositories/python-internal/simple"priority = "explicit"
[[tool.poetry.source]]name = "cloudrepo-shared"url = "https://your-org.mycloudrepo.io/repositories/python-shared/simple"priority = "explicit"Authentication Setup
Configure credentials for your private repository:
# Use environment variables (ideal for CI/CD). Poetry reads the credential for# the cloudrepo source from these two, so the token is not an argument.# The username is the email address of the account that created the token.export POETRY_HTTP_BASIC_CLOUDREPO_USERNAME=you@example.comexport POETRY_HTTP_BASIC_CLOUDREPO_PASSWORD="$CLOUDREPO_TOKEN"Publishing with Poetry
Poetry reads the repository you publish to from your own Poetry configuration, not from pyproject.toml: its
documentation treats it as user-specific, not project-specific.
Name it with poetry config, using the repository URL without /simple. The credential is the pair from the
section above:
poetry config repositories.cloudrepo https://your-org.mycloudrepo.io/repositories/pythonIn CI, set POETRY_REPOSITORIES_CLOUDREPO_URL to the same address instead.
Then build and publish:
# Build wheel and sdistpoetry build
# Publish to CloudRepopoetry publish -r cloudrepo
# Or build and publish in one commandpoetry publish --build -r cloudrepoComplete Poetry Example
Here’s a production-ready pyproject.toml for a team using CloudRepo:
[tool.poetry]name = "order-service"version = "2.1.0"description = "Order processing microservice"authors = ["Platform Team <platform@company.com>"]readme = "README.md"
[tool.poetry.dependencies]python = "^3.11"# Public dependenciesfastapi = "^0.109.0"uvicorn = {extras = ["standard"], version = "^0.27.0"}sqlalchemy = "^2.0.25"# Private dependencies, each from CloudRepo aloneacme-auth = { version = "^3.1.0", source = "cloudrepo" }acme-events = { version = "^2.0.0", source = "cloudrepo" }acme-observability = { version = "^1.5.0", source = "cloudrepo" }
[tool.poetry.group.dev.dependencies]pytest = "^7.4.4"pytest-asyncio = "^0.23.3"black = "^24.1.0"ruff = "^0.1.14"mypy = "^1.8.0"
[[tool.poetry.source]]name = "cloudrepo"url = "https://acme.mycloudrepo.io/repositories/python/simple"priority = "explicit"
[build-system]requires = ["poetry-core>=1.0.0"]build-backend = "poetry.core.masonry.api"This file names no publishing repository: that is the poetry config repositories.cloudrepo command above.
Setting Up uv with a Private Repository
uv is the blazing-fast Python package manager from the creators of Ruff. It’s gaining rapid adoption for its speed and pip compatibility.
Basic uv Configuration
uv does not read pip.conf or pip’s environment variables. Name CloudRepo as an index in
pyproject.toml (below), and give uv the login for it in two environment variables. uv’s
configuration files hold no credentials; it reads them from UV_INDEX_<NAME>_USERNAME and
UV_INDEX_<NAME>_PASSWORD for the index called <NAME>, written in upper case:
# The login for the index named "cloudrepo": the email address of the account that created# the token, and the tokenexport UV_INDEX_CLOUDREPO_USERNAME=you@example.comexport UV_INDEX_CLOUDREPO_PASSWORD=$CLOUDREPO_TOKENuv with pyproject.toml
In a project, pin each private package to CloudRepo. An index with explicit = true serves only
the packages that name it in [tool.uv.sources], and everything else comes from PyPI:
[project]name = "my-service"version = "1.0.0"requires-python = ">=3.10"dependencies = [ "requests>=2.28.0", "pydantic>=2.0.0", "acme-shared-lib>=1.2.0", "acme-utils>=1.0", # a private dependency of acme-shared-lib]
[project.optional-dependencies]dev = [ "pytest>=7.0.0", "black>=24.0.0",]
[[tool.uv.index]]name = "cloudrepo"url = "https://your-org.mycloudrepo.io/repositories/python/simple"explicit = true
# List every private package in dependencies, including the private packages# it depends on, and pin each one here: uv ignores a source for a package that# is not a dependency.[tool.uv.sources]acme-shared-lib = { index = "cloudrepo" }acme-utils = { index = "cloudrepo" }Lock the project, then install exactly what the lock names:
uv lockuv sync --lockeduv in CI/CD
uv’s speed makes it ideal for CI/CD pipelines:
name: CI
on: [push, pull_request]
jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4
- name: Install uv uses: astral-sh/setup-uv@v1
- name: Install dependencies env: UV_INDEX_CLOUDREPO_USERNAME: ${{ secrets.CLOUDREPO_USERNAME }} UV_INDEX_CLOUDREPO_PASSWORD: ${{ secrets.CLOUDREPO_TOKEN }} run: uv sync --extra dev # reads the [tool.uv.sources] pins
- name: Run tests run: uv run pytestPerformance Comparison
uv’s performance advantage is dramatic:
# Installing a project with 50 dependencies
# pip (traditional)time pip install -r requirements.txt# real 45.2s
# uvtime uv pip install -r requirements.txt# real 3.8s
# uv resolves and installs far faster than pipSuccess
uv’s Rust-based resolver installs packages far faster than pip, and CloudRepo’s cloud-hosted infrastructure serves them reliably from US-based infrastructure, a strong pairing for fast, reproducible CI/CD.
Best Practices for Package Versioning and Security
Semantic Versioning
Adopt semantic versioning (semver) for predictable upgrades:
MAJOR.MINOR.PATCH
1.0.0 - Initial release1.1.0 - New feature (backward compatible)1.1.1 - Bug fix2.0.0 - Breaking changeConfigure your dependencies to match:
[tool.poetry.dependencies]# Allow patches only (safest for production)critical-lib = "~1.2.0" # Allows 1.2.x
# Allow minor updates (good for active development)utility-lib = "^1.2.0" # Allows 1.x.x
# Pin exactly (for reproducibility)legacy-lib = "1.2.3" # Exactly 1.2.3Security Best Practices
1. Use API tokens, not passwords:
# Generate read-only tokens for CI/CD# Generate publish tokens only for release pipelines# Rotate tokens regularly2. Separate repositories by access level:
production-releases/ - Only tested, approved packagesstaging/ - Pre-release versions for testingdevelopment/ - Work-in-progress packages3. Audit your dependencies:
# Check for known vulnerabilitiespip-audit
# Or with Safetysafety check -r requirements.txt4. Lock your dependencies:
Always commit your lock files (poetry.lock, requirements.txt with pinned versions) to ensure reproducible builds across environments.
Package Naming Conventions
Avoid naming collisions with public packages:
# Good: a company prefix avoids clashing with existing public packagesacme-authacme-data-pipelineacme-ml-models
# Bad: Could conflict with public packagesauthdata-pipelinemodelsCost Comparison: The Real Numbers
Let’s calculate actual costs for a typical development team.
Scenario: 15 Developers, Active CI/CD
Usage profile:
- 100 GB stored artifacts
- 400 GB monthly downloads (CI/CD + developers)
- 50 GB monthly uploads
JFrog Artifactory Cost
Base price (Pro, list): $150/monthConsumption: 100 + 400 + 50 = 550 GBFirst 25 GB: IncludedNext 525 GB at $1.25/GB: $656.25/month ─────────────Monthly total: $806.25Annual total: $9,675Sonatype Nexus Cost
Base price (Pro Cloud): $162.50/month ($1,950/year)Consumption: 100 + 400 + 50 = 550 GBFirst 25 GB: IncludedNext 525 GB at $1.10/GB: $577.50/month ─────────────Monthly total: $740.00Annual total: $8,880CloudRepo Cost
Team plan: 250 GB storageData transfer: IncludedUsers: UnlimitedSupport: IncludedMonthly total: $499/month billed annually.
Annual Savings
JFrog publishes a monthly price for Pro, and Sonatype prices Nexus Pro Cloud by the year. Here are twelve months of each, against CloudRepo Team:
| Provider | 12 months | Billed | CloudRepo Team |
|---|---|---|---|
| Artifactory Pro, list | $9,675 | Monthly | $5,988/year billed annually: $3,687 lower |
| Nexus Pro Cloud | $8,880 | Annually | $5,988/year billed annually: $2,892 lower |
Pricing your whole team for a year? Open the ROI calculator
Important
For this scenario, CloudRepo Team comes in $3,687 a year below Artifactory’s list price, and $2,892 a year below Nexus Pro Cloud.
Hidden Costs Not Included Above
Artifactory/Nexus additional expenses:
- SSO/SAML integration: Enterprise tier required
- Advanced security features: Higher tier required
- Training and expertise: Internal DevOps time
CloudRepo includes:
- All features on every plan
- Human support from engineers
- SSO (SAML and OIDC)
- No surprise bills
Migration Guide: Moving to a Private Repository
From Nexus 3 or Artifactory
On a Nexus 3 server, one command moves its hosted Python (PyPI) repositories and checks every file it moves: pipx install cloudrepo-migrate installs our migration tool, and a dry run lists what would move and uploads nothing.
pipx install cloudrepo-migratecloudrepo-migrate nexus3 --nexus-url https://nexus.example.com --org your-org --dry-runWithout --dry-run, cloudrepo-migrate nexus3 copies every wheel and sdist of each hosted PyPI repository, each under the project name its own metadata gives. It then reads each file back from CloudRepo, compares its sha1 and sha256 with the bytes the server gave, and checks that the project’s index lists it. A Nexus 3 proxy or group repository is skipped, and the report says why. Version 0.6.0 of cloudrepo-migrate is tested on Nexus 3.76.0 with hosted PyPI repositories.
Create the target Python repository in CloudRepo first, with the Nexus repository’s name or mapped with --repo SOURCE=TARGET, and a repository token that can write it. The tool reads your Nexus login from NEXUS_USERNAME and NEXUS_PASSWORD, and your CloudRepo token from CLOUDREPO_TOKEN: set them in your shell, never on the command line. The Nexus 3 migration guide has the steps.
On an Artifactory server, version 0.6.0 of cloudrepo-migrate artifactory moves its local Maven repositories and does not move its Python repositories. Its migration guide covers Maven. To bring an Artifactory Python repository, download its packages and upload them with twine upload, or contact CloudRepo support for assisted migration.
From Git Dependencies
If you’re currently using Git URLs in requirements:
git+ssh://git@github.com/company/shared-lib.git@v1.2.3git+https://github.com/company/auth-lib.git@main- Build and publish packages to CloudRepo
- Update your requirements:
--index-url https://your-org.mycloudrepo.io/repositories/python/simpleacme-shared-lib==1.2.3acme-auth-lib==2.0.0From Self-Hosted PyPI
For a server cloudrepo-migrate does not read, such as a self-hosted PyPI server, upload your existing packages to CloudRepo with twine. Create the Python repository first and set CLOUDREPO_TOKEN in your shell to a repository token that can write it. twine takes its credential from environment variables: the username is the email address of the account that created the token, and the password is the token.
export TWINE_USERNAME="you@example.com"export TWINE_PASSWORD="$CLOUDREPO_TOKEN"twine upload --non-interactive \ --repository-url https://your-org.mycloudrepo.io/repositories/your-repo \ /var/pypi/packages/*.whlyour-repo is the name of the repository you created, and /var/pypi/packages is where your old server keeps its wheels.
Getting Started with CloudRepo
Ready to simplify your Python package management? Here’s how to get started:
1. Sign Up
Create a free account at cloudrepo.io/signup. No credit card required for the trial.
2. Create a Python Repository
# Via the web UI: Click "New Repository" > Select "Python"# Or via API. curl reads the Authorization header from standard input (-H @-)printf 'Authorization: Bearer %s\n' "$CLOUDREPO_TOKEN" | curl -X POST https://api.cloudrepo.io/v1/repositories \ -H @- \ -H "Content-Type: application/json" \ -d '{"name": "python", "type": "python"}'3. Configure Your Tools
pip:
pip config set global.index-url https://your-org.mycloudrepo.io/repositories/python/simpleA global index sends every install to that repository, and a hosted repository holds only what you uploaded, so pip install requests fails with No matching distribution found. Use a proxy repository as the global index for public packages, and install your private packages in a step of their own with –index-url.
Poetry:
poetry source add --priority=explicit cloudrepo https://your-org.mycloudrepo.io/repositories/python/simple/poetry add acme-shared-lib acme-utils --source cloudrepoPoetry asks an explicit source only for the packages that name it, so your private packages come from CloudRepo and every other package still comes from PyPI. Name each private package, including the private packages it depends on, in the same poetry add. Do not add CloudRepo as a primary source: that turns off Poetry’s PyPI, and a hosted repository holds only what you uploaded, so requests would not be found.
uv:
[[tool.uv.index]]name = "cloudrepo"url = "https://your-org.mycloudrepo.io/repositories/python/simple/"explicit = true
[tool.uv.sources]acme-shared-lib = { index = "cloudrepo" }uv asks an index with explicit = true only for the packages you pin to it in [tool.uv.sources], and every other package still comes from PyPI. Do not set UV_INDEX_URL to a hosted repository: it replaces PyPI as uv’s default index, so uv pip install requests stops with a package-not-found error. UV_INDEX fails the same way: uv stops at the first index that answers for a package, and CloudRepo answers a name it does not hold with an empty page. Pin each private package, including the private packages it depends on.
4. Upload Your First Package
twine upload \ --repository-url https://your-org.mycloudrepo.io/repositories/python/ \ dist/*That’s it. Your private Python repository is ready.
Questions about setting up your private Python repository? Our engineering team provides hands-on support for every customer. Start your 14-day free trial or contact us for a personalized walkthrough. We’ll help you get set up in minutes, not days.
Competitor figures in this guide were read on 2026-10-06 from JFrog’s pricing (archived), JFrog’s Pro checkout (archived), JFrog’s support page (archived), Sonatype’s pricing (archived) and Sonatype’s usage limits (archived). Vendors change prices and plans, so check each page before you decide.