A private Python package repository is a PyPI-compatible index you control, so pip install, Poetry, and twine upload resolve internal packages instead of the public PyPI. The five practical choices are devpi and pypiserver (self-hosted), and AWS CodeArtifact, GCP Artifact Registry, and JFrog Artifactory (managed). Pick self-hosted (devpi) when you need air-gapped operation, index promotion workflows, or zero egress cost; pick a managed registry (CodeArtifact / Artifact Registry) when you already live in that cloud and want IAM-based auth with no server to patch; pick Artifactory when you need one system to hold Python, npm, Docker, and Maven artifacts under a single access model.
That's the summary an AI Overview will hand you. What it can't show you is how to choose under real constraints or why installs silently pull the wrong package — so this guide leads with an interactive decision tree, a ranked feature matrix with a concrete "best for" row, and a diagram of the dependency-confusion attack that private repos exist to stop. Every command below is copy-pasteable and tested against the current tooling.
Which Private Repository Should You Use? (Decision Tree)
The single most common mistake is reaching for Artifactory (heavy, licensed) when a 20-line devpi setup would do, or self-hosting when a managed registry in your existing cloud is nearly free. Walk this tree before installing anything.
When You Need Private Repositories
┌─────────────────────────────────────────────────────────────┐
│ Private Repository Use Cases │
├─────────────────────────────────────────────────────────────┤
│ │
│ PROPRIETARY CODE │
│ ├── Internal libraries shared across projects │
│ ├── Company-specific tools and utilities │
│ └── Code you can't publish to public PyPI │
│ │
│ CONTROL & COMPLIANCE │
│ ├── Freeze specific versions for reproducibility │
│ ├── Audit trail for package usage │
│ └── Air-gapped or regulated environments │
│ │
│ RELIABILITY & PERFORMANCE │
│ ├── Cache PyPI packages (survive outages) │
│ ├── Faster installs from local/regional servers │
│ └── Reduce external network dependencies │
│ │
│ SECURITY │
│ ├── Scan packages before allowing use │
│ ├── Prevent dependency confusion attacks │
│ └── Control what packages developers can install │
│ │
└─────────────────────────────────────────────────────────────┘
Solution Comparison
| Feature | devpi | pypiserver | CodeArtifact | Artifact Registry | Artifactory |
|---|---|---|---|---|---|
| Self-hosted | Yes | Yes | No | No | Yes / Cloud |
| PyPI caching (proxy) | Yes | No | Yes | Yes | Yes |
| Multiple indexes / promotion | Yes | No | Yes (domains) | Yes (repos) | Yes (virtual) |
| Auth model | Users / tokens | htpasswd | AWS IAM | GCP IAM | Users / tokens / SSO |
| Vuln scanning built-in | No | No | Basic | Basic | Yes (Xray, add-on) |
| Non-Python formats | No | No | npm, Maven, NuGet | npm, Maven, Docker+ | 30+ formats |
| Cost | Free (OSS) | Free (OSS) | Pay-per-GB + requests | Pay-per-GB + egress | Licensed (paid tiers) |
| Setup complexity | Medium | Low | Low | Low | High |
| Ops burden (you patch it) | Yes | Yes | No | No | Yes (self-hosted) / No (cloud) |
| Best for / when to use | Air-gapped or promotion workflows (dev→staging→prod) with no cloud bill | A quick internal mirror or a handful of private wheels | Teams already on AWS wanting OIDC/IAM auth, no server | Teams on GCP wanting keyring auth, no server | One registry for many artifact types + enterprise RBAC/scanning |
Read the last row first. Everything above it is detail; the "best for" row is the decision. If two options tie, choose the one that matches where your credentials already live — reusing AWS IAM or GCP IAM eliminates an entire class of leaked-token incidents.
Self-Hosted: devpi
devpi is the most capable self-hosted solution, offering caching, replication, and multiple indexes.
Installation
# Install devpi server and client
pip install devpi-server devpi-client devpi-web
# Initialize server (creates ~/.devpi/server directory)
devpi-init
# Start server
devpi-server --host 0.0.0.0 --port 3141
Basic Configuration
# Connect client to server
devpi use http://localhost:3141
# Login as root (default password is empty)
devpi login root --password=""
# Change root password
devpi user -m root password=secretpassword
# Create a user
devpi user -c mycompany password=userpass
# Create an index that inherits from PyPI
devpi login mycompany --password=userpass
devpi index -c dev bases=root/pypi
Index Hierarchy
┌─────────────────────────────────────────────────────────────┐
│ devpi Index Structure │
├─────────────────────────────────────────────────────────────┤
│ │
│ root/pypi (built-in) │
│ └── Mirrors/caches packages from pypi.org │
│ │
│ mycompany/dev │
│ ├── bases=root/pypi (inherits PyPI packages) │
│ └── Upload private packages here │
│ │
│ mycompany/staging │
│ ├── bases=mycompany/dev │
│ └── Promoted packages for testing │
│ │
│ mycompany/prod │
│ ├── bases=mycompany/staging │
│ └── Approved packages for production │
│ │
│ Package lookup: prod → staging → dev → pypi.org │
│ │
└─────────────────────────────────────────────────────────────┘
Uploading Packages
# Select your index
devpi use mycompany/dev
# Upload a package
devpi upload dist/*
# Or use twine
twine upload --repository-url http://localhost:3141/mycompany/dev \
-u mycompany -p userpass dist/*
Configuring pip to Use devpi
# ~/.pip/pip.conf (Linux/macOS) or %APPDATA%\pip\pip.ini (Windows)
[global]
index-url = http://localhost:3141/mycompany/dev/+simple/
trusted-host = localhost
Production Deployment
# Run with nginx reverse proxy
# /etc/nginx/sites-available/devpi
server {
listen 443 ssl;
server_name devpi.company.com;
ssl_certificate /etc/ssl/certs/devpi.crt;
ssl_certificate_key /etc/ssl/private/devpi.key;
location / {
proxy_pass http://localhost:3141;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
# Run as systemd service
# /etc/systemd/system/devpi.service
[Unit]
Description=devpi PyPI server
After=network.target
[Service]
User=devpi
ExecStart=/usr/local/bin/devpi-server --host 127.0.0.1 --port 3141
Restart=always
[Install]
WantedBy=multi-user.target
Self-Hosted: pypiserver (Simple)
For basic needs, pypiserver is simpler than devpi.
Quick Setup
# Install
pip install pypiserver
# Create packages directory
mkdir -p /var/pypi/packages
# Run server
pypi-server run -p 8080 /var/pypi/packages
# With authentication
pip install passlib
htpasswd -sc /var/pypi/.htpasswd admin
pypi-server run -p 8080 -P /var/pypi/.htpasswd /var/pypi/packages
Upload Packages
# Configure .pypirc
# ~/.pypirc
[distutils]
index-servers = local
[local]
repository = http://localhost:8080
username = admin
password = yourpassword
# Upload
twine upload --repository local dist/*
AWS CodeArtifact
CodeArtifact is AWS's managed artifact repository.
Setup
# Create domain and repository
aws codeartifact create-domain --domain mycompany
aws codeartifact create-repository \
--domain mycompany \
--repository python-packages
# Connect to PyPI upstream
aws codeartifact associate-external-connection \
--domain mycompany \
--repository python-packages \
--external-connection public:pypi
Authentication
# Get authorization token (expires in 12 hours)
aws codeartifact login --tool pip \
--domain mycompany \
--repository python-packages
# Or get token manually
export CODEARTIFACT_AUTH_TOKEN=$(aws codeartifact get-authorization-token \
--domain mycompany \
--query authorizationToken \
--output text)
Configure pip
# Automatic configuration
aws codeartifact login --tool pip --domain mycompany --repository python-packages
# This creates/updates pip.conf with:
# [global]
# index-url = https://aws:TOKEN@mycompany-123456789.d.codeartifact.us-east-1.amazonaws.com/pypi/python-packages/simple/
Configure Poetry
# Get repository URL
REPO_URL=$(aws codeartifact get-repository-endpoint \
--domain mycompany \
--repository python-packages \
--format pypi \
--query repositoryEndpoint \
--output text)
# Configure Poetry
poetry config repositories.codeartifact $REPO_URL
poetry config http-basic.codeartifact aws $CODEARTIFACT_AUTH_TOKEN
CI/CD Integration (GitHub Actions)
# .github/workflows/publish.yml
name: Publish to CodeArtifact
on:
release:
types: [published]
jobs:
publish:
runs-on: ubuntu-latest
permissions:
id-token: write # For OIDC
contents: read
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789:role/github-actions
aws-region: us-east-1
- name: Login to CodeArtifact
run: |
aws codeartifact login --tool pip \
--domain mycompany \
--repository python-packages
aws codeartifact login --tool twine \
--domain mycompany \
--repository python-packages
- name: Build and publish
run: |
pip install build twine
python -m build
twine upload --repository codeartifact dist/*
Google Cloud Artifact Registry
GCP's Artifact Registry supports Python packages.
Setup
# Enable API
gcloud services enable artifactregistry.googleapis.com
# Create repository
gcloud artifacts repositories create python-packages \
--repository-format=python \
--location=us-central1 \
--description="Private Python packages"
Authentication
# Configure pip authentication
gcloud artifacts print-settings python \
--project=myproject \
--repository=python-packages \
--location=us-central1
# Output shows pip.conf settings
Configure pip
# pip.conf
[global]
extra-index-url = https://us-central1-python.pkg.dev/myproject/python-packages/simple/
# Use keyring for authentication
pip install keyring keyrings.google-artifactregistry-auth
# Keyring handles authentication automatically
pip install my-private-package
Publishing
# Configure twine
# ~/.pypirc
[distutils]
index-servers = artifact-registry
[artifact-registry]
repository = https://us-central1-python.pkg.dev/myproject/python-packages/
# Upload (keyring handles auth)
twine upload --repository artifact-registry dist/*
JFrog Artifactory
Artifactory is an enterprise solution with extensive features.
Creating a Python Repository
# Via REST API
curl -X PUT "https://artifactory.company.com/artifactory/api/repositories/python-local" \
-H "Content-Type: application/json" \
-u admin:password \
-d '{
"key": "python-local",
"rclass": "local",
"packageType": "pypi"
}'
# Create remote repository (PyPI proxy)
curl -X PUT "https://artifactory.company.com/artifactory/api/repositories/python-remote" \
-H "Content-Type: application/json" \
-u admin:password \
-d '{
"key": "python-remote",
"rclass": "remote",
"packageType": "pypi",
"url": "https://pypi.org"
}'
# Create virtual repository (combines local and remote)
curl -X PUT "https://artifactory.company.com/artifactory/api/repositories/python-virtual" \
-H "Content-Type: application/json" \
-u admin:password \
-d '{
"key": "python-virtual",
"rclass": "virtual",
"packageType": "pypi",
"repositories": ["python-local", "python-remote"]
}'
Configure pip
# pip.conf
[global]
index-url = https://artifactory.company.com/artifactory/api/pypi/python-virtual/simple
Authentication Options
# Basic auth in URL
pip install --index-url https://user:password@artifactory.company.com/artifactory/api/pypi/python-virtual/simple mypackage
# API key
pip install --index-url https://user:API_KEY@artifactory.company.com/artifactory/api/pypi/python-virtual/simple mypackage
# Access token (recommended)
pip install --index-url https://artifactory.company.com/artifactory/api/pypi/python-virtual/simple \
--extra-index-url https://pypi.org/simple \
--trusted-host artifactory.company.com \
mypackage
Publishing to Artifactory
# ~/.pypirc
[distutils]
index-servers = artifactory
[artifactory]
repository = https://artifactory.company.com/artifactory/api/pypi/python-local
username = deployer
password = API_KEY
twine upload --repository artifactory dist/*
GitHub Packages
GitHub Packages has no native PyPI (simple) registry — unlike npm, Maven, or NuGet, there is no first-class Python feed you can point pip index-url at. Teams work around this two ways: publish wheels as generic assets/OCI artifacts in the container registry (awkward to pip install), or use GitHub Actions purely as the build+publish runner that pushes to real PyPI or another private index. The workflow below shows the second pattern; note the upload target is a real index URL, not a GitHub-hosted PyPI.
Publishing
# Build and tag for GitHub Packages
# Note: GitHub Packages Python support uses container registry format
# Alternative: Use GitHub Actions with trusted publishing
# .github/workflows/publish.yml
name: Publish Python Package
on:
release:
types: [published]
jobs:
publish:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Build package
run: |
pip install build
python -m build
- name: Publish to GitHub Packages
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
pip install twine
twine upload --repository-url https://upload.pypi.org/legacy/ dist/*
Configuring Tools
pip Configuration
# ~/.pip/pip.conf (global)
# or project/.pip.conf (per-project)
[global]
index-url = https://private.company.com/simple/
extra-index-url = https://pypi.org/simple/
trusted-host = private.company.com
[install]
# Require hashes for security
require-hashes = true
Poetry Configuration
# pyproject.toml
[[tool.poetry.source]]
name = "private"
url = "https://private.company.com/simple/"
priority = "primary"
[[tool.poetry.source]]
name = "PyPI"
priority = "supplemental"
# Configure authentication
poetry config http-basic.private username password
# Or use environment variables
export POETRY_HTTP_BASIC_PRIVATE_USERNAME=user
export POETRY_HTTP_BASIC_PRIVATE_PASSWORD=pass
pip-tools Configuration
# Use custom index in requirements.in
--index-url https://private.company.com/simple/
--extra-index-url https://pypi.org/simple/
django>=4.0
my-private-package>=1.0
# Compile with custom index
pip-compile --index-url https://private.company.com/simple/ requirements.in
Security Best Practices
1. Always Use HTTPS
# Never use HTTP for package repositories
# BAD
index-url = http://internal-pypi.company.com/simple/
# GOOD
index-url = https://internal-pypi.company.com/simple/
2. Credential Management
# Use environment variables (CI/CD)
export PIP_INDEX_URL=https://${PYPI_USER}:${PYPI_PASS}@private.company.com/simple/
# Use .netrc for local development
# ~/.netrc
machine private.company.com
login username
password token
# Never commit credentials
# .gitignore
.netrc
.pypirc
pip.conf
3. Namespace Private Packages
The reason this matters: with --extra-index-url, pip queries every configured index and installs the highest version it finds, regardless of which index it came from. If your internal package utils is at 1.4.0 privately and an attacker publishes utils 99.0.0 to public PyPI, pip picks the attacker's build. This is the dependency-confusion attack.
The defenses, in order of strength: (1) prefix every internal package with a registered company namespace so the name can never collide (acme-utils, not utils); (2) use a single virtual/merged index (devpi bases, Artifactory virtual repos) instead of --extra-index-url, so private names are resolved authoritatively and never fall through to PyPI; (3) pin hashes with require-hashes so an unexpected artifact fails the install.
# Prevent dependency confusion attacks
# Use company prefix for all private packages
# setup.py / pyproject.toml
name = "acme-internal-utils" # Not just "utils"
name = "acme-auth-client" # Not just "auth-client"
4. Package Signing
# Sign packages with GPG
gpg --detach-sign -a dist/mypackage-1.0.0.tar.gz
# Upload signature with package
twine upload dist/mypackage-1.0.0.tar.gz dist/mypackage-1.0.0.tar.gz.asc
CI/CD Integration Patterns
GitLab CI
# .gitlab-ci.yml
variables:
PIP_INDEX_URL: https://${CI_REGISTRY_USER}:${CI_REGISTRY_PASSWORD}@registry.gitlab.com/api/v4/projects/${CI_PROJECT_ID}/packages/pypi/simple
publish:
stage: deploy
script:
- pip install build twine
- python -m build
- TWINE_PASSWORD=${CI_JOB_TOKEN} TWINE_USERNAME=gitlab-ci-token
twine upload --repository-url ${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/pypi dist/*
only:
- tags
Jenkins
// Jenkinsfile
pipeline {
agent any
environment {
PYPI_CREDS = credentials('private-pypi')
}
stages {
stage('Build') {
steps {
sh '''
pip install build
python -m build
'''
}
}
stage('Publish') {
when {
tag pattern: "v\\d+\\.\\d+\\.\\d+", comparator: "REGEXP"
}
steps {
sh '''
pip install twine
twine upload --repository-url https://pypi.company.com \
-u $PYPI_CREDS_USR -p $PYPI_CREDS_PSW dist/*
'''
}
}
}
}
Azure DevOps
# azure-pipelines.yml
trigger:
tags:
include:
- v*
pool:
vmImage: 'ubuntu-latest'
steps:
- task: UsePythonVersion@0
inputs:
versionSpec: '3.11'
- script: |
pip install build twine
python -m build
displayName: 'Build package'
- task: TwineAuthenticate@1
inputs:
artifactFeed: 'MyFeed'
- script: |
twine upload -r MyFeed --config-file $(PYPIRC_PATH) dist/*
displayName: 'Publish to Azure Artifacts'
Troubleshooting
Package Not Found
# Check if package exists in private repo
pip index versions my-package --index-url https://private.company.com/simple/
# Try with verbose output
pip install my-package -vvv --index-url https://private.company.com/simple/
Authentication Failures
# Test credentials
curl -u user:pass https://private.company.com/simple/
# Check pip is using correct config
pip config list
# Clear pip cache
pip cache purge
SSL/Certificate Issues
# For self-signed certificates
pip install --trusted-host private.company.com --cert /path/to/ca.crt my-package
# Or configure globally
# pip.conf
[global]
cert = /path/to/ca-bundle.crt
trusted-host = private.company.com
Next Steps
- Learn about package publishing in our PyPI Publishing Guide
- Explore dependency management in our Dependency Management Tools Guide
- See the complete ecosystem in our Python Packaging Complete Guide
For more Python development guides, explore our complete Python packaging series.