Deploy your own private PyPI-compatible package registry on Railway in under 2 minutes.
Based on devpi, with persistent storage and full compatibility with pip, uv, and twine.
- A private Python package index served by
devpi-server+devpi-web(web UI + search). - Standard tooling: publish with
twineordevpi upload, install withpiporuv. - On-demand PyPI mirror: any package fetched through your index is cached in your volume.
- Persistent storage on a Railway volume — redeploys don't wipe your packages or logins.
- One service, one container, one volume. No Postgres, no reverse proxy, no moving parts.
- A Railway account.
- Local
pip/uv/twine/devpi-clientfor publishing and installing.
- Deploy the template on Railway (button above, or
railway initfrom a fork of this repo). - Attach a Volume to the service and mount it at
/data. This is mandatory — without it, your packages and logins are lost on every redeploy. - Set
DEVPI_ROOT_PASSWORDin the service variables before the first deploy. This seeds the devpirootaccount. If you skip it,rootstarts with an empty password and you'll have to change it manually viadevpi-client. - Generate a public domain in the Railway service settings (Networking → Generate Domain). Railway does not assign one automatically.
- Wait for the health check on
/+apito go green. You're live athttps://<your-domain>/.
The web UI is browse-only. devpi-web shows your indexes, packages, and search — but there's no web login form. All authenticated actions (logging in, creating users, creating indexes, uploading packages) go through the
devpi-clientCLI described below. This is by design in devpi.
pip install devpi-client
devpi use https://<your-domain>
devpi login root --password="$DEVPI_ROOT_PASSWORD"By default the server runs with --restrict-modify root, so only root can create users and indexes. While still logged in as root:
devpi user -c alice password=s3cret email=alice@example.com
devpi index -c alice/prod bases=root/pypiThen switch to the new user to publish:
devpi login alice --password=s3cret
devpi use alice/prodbases=root/pypi makes your index fall through to the on-demand PyPI mirror, so pip installing a public dependency through your index Just Works.
Want any authenticated user to create their own indexes? Set the service variable
DEVPI_RESTRICT_MODIFY=""and redeploy.
With devpi upload (builds + uploads from the current project):
devpi upload --formats sdist,bdist_wheelWith twine (from an existing dist/):
twine upload \
--repository-url https://<your-domain>/alice/prod/ \
--username alice --password s3cret \
dist/*With pip:
pip install \
--index-url https://alice:s3cret@<your-domain>/alice/prod/+simple/ \
my-private-pkgWith uv:
uv pip install \
--index https://alice:s3cret@<your-domain>/alice/prod/+simple/ \
my-private-pkgFor CI, prefer a scoped token over a password: see devpi-tokens.
| Variable | Default | Purpose |
|---|---|---|
PORT |
3141 |
Set by Railway. The server binds 0.0.0.0:$PORT. |
DEVPI_ROOT_PASSWORD |
(unset) | One-shot bootstrap of the root password on first init. Changing it later has no effect — use devpi user -m root password=.... |
DEVPI_SERVERDIR |
/data/server |
Where devpi stores packages, indexes, and metadata. Must be on the mounted volume. |
DEVPI_SECRETFILE |
/data/.secret |
Persistent server secret. Required so login tokens survive a redeploy. |
DEVPI_RESTRICT_MODIFY |
root |
Only root can create users/indexes. Set to an empty string to allow any logged-in user to create their own. |
DEVPI_THEME_DIR |
/app/theme |
Folder passed to devpi-server's --theme. Ships with a built-in modern theme (system fonts, dark mode). Point it at a folder under /data to use your own, or set to an empty string for the stock devpi look. |
DEVPI_OUTSIDE_URL |
(auto-detected) | Full public URL of the service, used by devpi-web to build absolute links. On Railway it is auto-derived from RAILWAY_PUBLIC_DOMAIN. Set it explicitly only if you front the service with a custom domain or another proxy. |
DEVPI_TRUSTED_PROXY |
* |
Passed to devpi-server --trusted-proxy. * trusts any proxy — required so X-Forwarded-Proto from Railway's edge is honoured and links are generated as https:// instead of http://. |
DEVPI_TRUSTED_PROXY_COUNT |
1 |
Number of proxies in front of the service. |
DEVPI_TRUSTED_PROXY_HEADERS |
x-forwarded-proto x-forwarded-for x-forwarded-host |
Forwarded headers devpi-server will trust. |
Everything stateful lives under /data:
/data/
├── server/ # devpi serverdir: packages, indexes, users
├── .secret # generated on first boot, keeps login tokens valid across redeploys
└── .initialized # sentinel so devpi-init runs exactly once
If you ever want a clean slate, delete the volume (or wipe .initialized) and redeploy.
- SQLite-backed (devpi default). Fine for small-to-medium teams. Postgres backend is not configured.
- No reverse proxy, no
devpi-lockdown. Anonymous reads of public indexes are allowed by devpi's default ACLs. - Single replica — no HA.
- Credentials in install URLs are visible in shell history. Use
devpi-tokensfor CI.
These are intentional trade-offs to keep the template simple and reliable. Fork it if you need more.
"I get a 404 at my domain." You forgot to Generate Domain in the Railway UI, or the health check on /+api hasn't gone green yet.
"My login tokens keep getting invalidated after a redeploy." The secret file isn't persisted — check that your volume is actually mounted at /data and that /data/.secret survives a restart.
"devpi upload says permission denied." You're using the wrong index. Run devpi use <user>/<index> and devpi login <user> before uploading.
"pip install can't find my private package." Double-check you're hitting /<user>/<index>/+simple/ (the +simple suffix is mandatory for PEP 503).
"Redeploy wiped my packages." No volume attached, or it's mounted somewhere other than /data. Re-check the service settings.
./scripts/smoke-test.shBuilds the image, boots a throwaway container, exercises the full user/index/upload/install flow, then restarts the container on the same volume to verify persistence.
The three runtime dependencies are pinned explicitly in requirements.txt. As checked on 17 October 2026, these match the latest stable PyPI releases: devpi-server 6.20.3, devpi-web 5.1.1, and devpi-client 7.3.0. Python stays on the existing 3.12 base; the smoke test pulls the current image and checks the installed dependency graph before exercising the registry.
Check upstream versions runs daily at 05:43 UTC, after monitored files change on main, or manually via Run workflow. It checks published PyPI releases, ignores prereleases and yanked candidates, and flags a withdrawn installed pin or a Python compatibility change. Its unit tests also run on pull requests without permission to write issues.
The workflow creates at most one open update issue per runtime dependency, refreshes it for newer releases, and closes it when the repository catches up. Closing as not planned suppresses that precise target version. Keep discussion in comments because the bot owns the issue body. An API failure fails the check and leaves issues untouched. GitHub may delay scheduled runs or disable them after 60 days without activity in a public repository; check the Actions tab if reminders stop.
Updates and deployments stay manual. Change the relevant pins on a branch, read the upstream release notes, and require a successful container smoke test before merging. Back up the complete /data volume, including the server secret, before deploying an upgrade; follow any devpi storage migration instructions. The watcher checks repository pins, not the versions inside an already deployed Railway service or every transitive dependency. It does not change Railway configuration or stored packages.
To run the watcher locally without creating issues:
python -m pip install 'packaging>=24,<27'
python scripts/check_versions.py
python -m unittest discover -s tests -p 'test_versions.py' -vMIT. See LICENSE.