repsyio/repsy has 37 GitHub stars and 6 forks. Star changes are not measured yet. Last checked 2026-11-08 17:07 UTC. Last push 2026-11-08.
Next-generation universal package repository | docker | maven | gradle | npm | pypi | go | cargo | nuget | helm | ruby ...
できること
- Repository Management: Create, manage, and organize repositories
- Multi-Protocol Support: Golang, Cargo (Rust), Maven, npm, PyPI, Docker registries
- User Authentication: Secure JWT-based authentication
- Deploy Tokens: Secure token-based deployment mechanism
計測したスター増加
- 24時間
- 更新が遅れています
- 7日間
- 更新が遅れています
- 15日間
- 更新が遅れています
- 1か月
- 更新が遅れています
期間ごとの純増数です。期間は重複しており、日別の値ではありません。未計測の値は空欄です。スター数は注目度の指標であり、品質の保証ではありません。
README.md
オリジナルRepsy: The Open Source Universal Package Repository
Repsy is an open-source, universal package repository that makes it easy to host, manage, and distribute your packages across multiple ecosystems — all in one place. With support for popular formats including Golang, Cargo (Rust), Docker, Maven, NPM, PyPI, and more, Repsy helps streamline your development workflows and supports teams of any size.
Table of Contents
- Features
- Quick Start
- HTTPS / SSL
- Vulnerability Scanning
- Installation
- Backward incompatible changes in this release
- Upgrading
- Configuration
- Content Security Policy
- Cross-Origin Requests (CORS)
- Security headers
- Usage
- Reverse Proxy
- Troubleshooting
- Development
- License
Features
- Repository Management: Create, manage, and organize repositories
- Multi-Protocol Support: Golang, Cargo (Rust), Maven, npm, PyPI, Docker registries
- User Authentication: Secure JWT-based authentication
- Deploy Tokens: Secure token-based deployment mechanism
- Real-time Dashboard: Monitor repository activity
- RESTful API: Comprehensive REST API
- Database Support: H2 (embedded) and PostgreSQL
- Docker Ready: Complete containerization support
- Vulnerability Scanning: Vulnerability scanning (Maven, npm, PyPI, Docker) via an optional Trivy-based scanner service.
Quick Start
The fastest way to get Repsy up and running (uses embedded H2 database — no external dependencies):
docker run -d \
--name repsy \
-p 8080:8080 \
-p 9090:9090 \
repo.repsy.io/repsy/os/repsy:latest
Access the application:
- Backend API & Frontend (Web UI): http://localhost:8080
- Repository Operations: http://localhost:9090
Default Admin Credentials:
- Username:
admin - Password: since
ADMIN_INITIAL_PASSWORDis not set, a random password is generated on first startup. Retrieve it from the logs:docker logs repsy | grep "password"
Note: In the image, the H2 database (
/app/data/repsy) and the artifact files (STORAGE_BASE_PATH, default/app/data/storage) both live under/app/data, so one volume keeps both. The command above has no volume: everything is lost when the container is removed. Add-v repsy-data:/app/datato keep it (see Option 1).
HTTPS / SSL
Repsy OS supports optional, per-port HTTPS. HTTP ports (8080, 9090) remain open at all times — SSL adds new ports alongside them and does not replace them.
Generating a self-signed certificate
keytool -genkeypair \
-alias repsy \
-keyalg RSA \
-keysize 2048 \
-storetype PKCS12 \
-keystore keystore.p12 \
-validity 365 \
-storepass changeit \
-keypass changeit \
-dname "CN=localhost, O=Repsy" \
-ext "SAN=DNS:localhost,IP:127.0.0.1"
Environment variables
| Variable | Description | Default |
|---|---|---|
API_SSL_ENABLED |
Enable HTTPS for the API port | false |
API_SSL_PORT |
HTTPS port for the API | 8443 |
API_SSL_KEY_STORE_PATH |
Path to keystore file (e.g. file:/app/certs/api.p12) |
— |
API_SSL_KEY_STORE_PASSWORD |
Keystore password | — |
API_SSL_KEY_STORE_TYPE |
Keystore type | PKCS12 |
API_SSL_KEY_ALIAS |
Key alias inside the keystore | repsy |
REPO_SSL_ENABLED |
Enable HTTPS for the repo port | false |
REPO_SSL_PORT |
HTTPS port for the repo | 9443 |
REPO_SSL_KEY_STORE_PATH |
Path to keystore file (e.g. file:/app/certs/repo.p12) |
— |
REPO_SSL_KEY_STORE_PASSWORD |
Keystore password | — |
REPO_SSL_KEY_STORE_TYPE |
Keystore type | PKCS12 |
REPO_SSL_KEY_ALIAS |
Key alias inside the keystore | repsy |
Docker run example
docker run \
-v /host/certs/keystore.p12:/app/certs/keystore.p12 \
-e API_SSL_ENABLED=true \
-e API_SSL_KEY_STORE_PATH=file:/app/certs/keystore.p12 \
-e API_SSL_KEY_STORE_PASSWORD=changeit \
-e REPO_SSL_ENABLED=true \
-e REPO_SSL_KEY_STORE_PATH=file:/app/certs/keystore.p12 \
-e REPO_SSL_KEY_STORE_PASSWORD=changeit \
-p 8080:8080 -p 8443:8443 -p 9090:9090 -p 9443:9443 \
repsy-os:latest
File permission note
Mounted keystore files must be readable by the container user. On the host, ensure the file has at least mode
644:chmod 644 /host/certs/keystore.p12
Production note
For production deployments, use a CA-signed certificate instead of a self-signed one. Self-signed certificates will cause
x509: certificate signed by unknown authorityerrors in clients unless the certificate is explicitly trusted.
Vulnerability Scanning
Repsy can scan pushed artifacts (Maven, npm, PyPI, Docker) for known vulnerabilities using a separate repsy-scanner-trivy service. This is disabled by default (SECURITY_SCANNER=disabled) and adds no dependency to a plain install. To enable it, run the repsy-scanner-trivy service (see Option 3 and repsy-scanner-trivy/README.md) and set SECURITY_SCANNER=enabled along with the TRIVY_*/DOCKER_INTERNAL_REGISTRY_BASE_URL variables in Environment Variables.
The scanner is published with every release as repo.repsy.io/repsy/os/repsy-scanner-trivy, under the same tags as the application image (repo.repsy.io/repsy/os/repsy): a release tag without the leading v (for example 26.10.0) and latest. Run the application and the scanner of the same release: the HTTP contract between them (POST /scan, GET /scan/{scanId}, the X-Scanner-Api-Key header) is not versioned, so a mixed pair is not supported. examples/docker-compose.scanner.yml is a complete Compose example (PostgreSQL, Repsy, the scanner and the trivy-cache volume) that pins both images with one REPSY_VERSION.
Each repository has a security scan setting that controls whether newly pushed versions are scanned automatically. It does not block manual scans: a version can always be scanned on demand from the panel or with POST /api/repos/{repoName}/artifacts/{artifactName}/versions/{version}/scan (a scoped npm package: POST /api/repos/{repoName}/scopes/{scope}/artifacts/{artifactName}/versions/{version}/scan; both answer 202 with a Location of the scan status resource), even when the repository's setting is off.
What a scan covers
A scan covers what the artifact contains, not what it declares. For Maven, npm and PyPI the scanner unpacks the stored file and runs trivy rootfs on it. rootfs reads installed packages (a node_modules directory, jars, a Python .dist-info); it does not read lock files and does not resolve declared dependencies. So:
| Format | What is scanned | What is not |
|---|---|---|
| npm | the tarball: the package's own metadata (package/package.json) and packages bundled in it (node_modules/*/package.json) |
the package's declared dependencies, devDependencies and peerDependencies, and any package-lock.json or yarn.lock in the tarball |
| Maven | the main file of the version (a jar, or a war, ear or rar for that packaging), including the jars nested in it | the dependencies declared in the POM: a thin jar is scanned as itself only |
| PyPI | the sdist (.tar.gz) if the release has one, otherwise the first matching file, such as a wheel; the package's own metadata |
the Requires-Dist dependencies |
| Docker | the whole image, which the scanner pulls from Repsy by reference |
A package that declares vulnerable dependencies without bundling them is therefore reported as having no findings, and a clean scan does not mean the dependency tree is clean. Scans use the Trivy vulnerability database the scanner holds locally, and only the four formats above are scanned.
Auditing npm packages
npm audit, pnpm audit, yarn npm audit and bun audit work against a Repsy npm repository. They report the vulnerabilities of the package versions they ask about, from two sources that are merged: what the scanner's vulnerability database knows about those exact package name and version pairs (a lookup made when the audit runs, whether or not Repsy ever stored or scanned the version), and the findings of the scans of that repository (the latest completed scan of each version). A vulnerability that both report is listed once, as the stored finding. With the repository's security scan setting off (the scanner is disabled, or scanning is turned off for that repository, even if an earlier scan left findings) they report none and exit with 0, and Repsy does not ask the scanner. Repsy reports as vulnerable only the versions the audit asks about, one by one, never a range such as <1.2.3, so it never flags a version the audit did not name.
The lookup asks the scanner's local Trivy database, so it closes the gaps a scan leaves: a version that was never scanned or was scanned before the database learned of the advisory, and a version that is not stored in the repository at all (the pairs an audit sends are all looked up). A private package whose name equals a public one gets the public package's advisories, as it would on npmjs.org. The stored findings still count: a scan reports the vulnerabilities of a package's own metadata (its package.json and any bundled dependencies in node_modules/), and does not resolve declared dependencies from ranges in package.json. The lookup is best-effort and never fails an audit: while the scanner runs a scan (it holds its database for the run, so it answers 503, and a scan of a large Docker image can take a while), for the few seconds it swaps its database after a refresh, before it has downloaded a database, when it is slow (longer than TRIVY_REQUEST_TIMEOUT_SECONDS, any other non-200 answer included) or unreachable, or when TRIVY_ADVISORY_LOOKUP_MAX_CONCURRENCY audits are already waiting on it, the audit is answered from the stored findings only and the reason is logged as a warning. So an empty audit does not always mean a dependency is safe, and two audits a moment apart can report different findings when the second finds the scanner busy. More than 20,000 name and version pairs are looked up in requests of 20,000, at most five of them; pairs the scanner would refuse (a name that is not an npm package name, a text that is too long) are left out. Stored findings are frozen at the time of the scan (a rescan refreshes them); the lookup reads the scanner's current database, which the scanner refreshes on a schedule. The Repsy panel shows no dependency graph or transitive view of a library. An audit request is limited to 8 MiB (inflated) and 20,000 packages. yarn audit (Yarn 1) always queries registry.yarnpkg.com and never reaches Repsy. npm whoami, npm ping and npm search are answered as well: whoami needs credentials even on a public repository, and search looks only at the packages of the repository in the URL. search understands the scope:, keywords:, author:, maintainer:, is:/not: (deprecated, unstable, insecure) and boost-exact: qualifiers, size (0 to 250) and from; a text of qualifiers Repsy cannot filter on matches no package, and a size or from that is not a whole number of 0 or more is answered with 400. npm logout and pnpm logout revoke the token of an npm login (DELETE /-/user/token/<token>, 200 {"ok": true}) so that it is refused from then on; the secret of a deploy token is refused (403 deployTokenNotRevocable, with an error text that says so), because a deploy token is managed in the web UI and revoking it would take it away from every CI job that shares it. So a CI job that authenticates with a deploy token should not run npm logout (or should ignore its exit code): npm exits non-zero (E403, and prints the text) and keeps the token in .npmrc, pnpm exits non-zero (ERR_PNPM_LOGOUT_FAILED), and revoking the deploy token is done in the web UI. With Basic _auth (username:password in .npmrc) there is no token to revoke: npm logout stops with ENEEDAUTH and pnpm logout with ERR_PNPM_NOT_LOGGED_IN, both before they call the registry.
Installation
Option 1: Docker with H2 (Embedded Database)
No external database required. Suitable for evaluation and development.
docker run -d \
--name repsy \
-p 8080:8080 \
-p 9090:9090 \
-e ADMIN_INITIAL_PASSWORD=YourSecurePassword123 \
-v repsy-data:/app/data \
repo.repsy.io/repsy/os/repsy:latest
The
-v repsy-data:/app/dataflag persists the H2 database and the artifact files (/app/data/storage) across container recreation. Without a volume both are lost when the container is removed.
Option 2: Docker with PostgreSQL
# 1. Create a shared network
docker network create repsy-network
# 2. Start PostgreSQL
docker run -d \
--name repsy-postgres \
--network repsy-network \
-e POSTGRES_DB=repsy \
-e POSTGRES_USER=repsy \
-e POSTGRES_PASSWORD=repsy123 \
-v repsy-pgdata:/var/lib/postgresql \
-p 5432:5432 \
postgres:18
# 3. Start Repsy
docker run -d \
--name repsy \
--network repsy-network \
-p 8080:8080 \
-p 9090:9090 \
-e DB_URL=jdbc:postgresql://repsy-postgres:5432/repsy \
-e DB_USERNAME=repsy \
-e DB_PASSWORD=repsy123 \
-e ADMIN_INITIAL_PASSWORD=YourSecurePassword123 \
-v repsy-data:/app/data \
repo.repsy.io/repsy/os/repsy:latest
Two volumes keep this install across container recreation:
repsy-datafor the artifact files (/app/data/storage) andrepsy-pgdatafor the database. Mount PostgreSQL 18's volume at/var/lib/postgresql: it keeps its data in a versioned directory below that path, so a volume at/var/lib/postgresql/datadoes not persist it.
Option 3: Docker Compose with PostgreSQL
services:
postgres:
container_name: repsy-postgres
hostname: repsy-postgres
image: postgres:18
environment:
- POSTGRES_DB=repsy
- POSTGRES_USER=repsy
- POSTGRES_PASSWORD=repsy123
volumes:
- repsy-pgdata:/var/lib/postgresql
ports:
- "5432:5432"
networks:
- repsy-network
repsy:
container_name: repsy
image: repo.repsy.io/repsy/os/repsy:latest
depends_on:
- postgres
ports:
- "8080:8080"
- "9090:9090"
environment:
- DB_URL=jdbc:postgresql://repsy-postgres:5432/repsy
- DB_USERNAME=repsy
- DB_PASSWORD=repsy123
- ADMIN_INITIAL_PASSWORD=YourSecurePassword123
volumes:
- repsy-data:/app/data
networks:
- repsy-network
networks:
repsy-network:
driver: bridge
volumes:
repsy-data:
repsy-pgdata:
Adding vulnerability scanning to the stack
To enable vulnerability scanning, add the following service to your docker-compose.yml (a complete file is examples/docker-compose.scanner.yml). Use the same tag as your repsy service instead of latest when you pin a release:
repsy-scanner-trivy:
container_name: repsy-scanner-trivy
hostname: repsy-scanner-trivy
image: repo.repsy.io/repsy/os/repsy-scanner-trivy:latest
environment:
- SCANNER_API_KEY=${TRIVY_SCANNER_API_KEY:?set TRIVY_SCANNER_API_KEY to a shared secret}
ports:
- "8090:8090"
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
- trivy-cache:/home/appuser/.cache/trivy
healthcheck:
test: ["CMD", "wget", "-q", "-O-", "http://localhost:8090/health"]
interval: 30s
timeout: 5s
start_period: 10s
retries: 3
networks:
- repsy-network
volumes:
trivy-cache:
This supports two topologies:
- (a) Full compose — every service (
postgres,repsy,repsy-scanner-trivy) runs in the compose stack. Therepsyservice's environment getsSECURITY_SCANNER=enabled,TRIVY_SCANNER_BASE_URL=http://repsy-scanner-trivy:8090, andDOCKER_INTERNAL_REGISTRY_BASE_URL=http://repsy:9090(service-name-based DNS resolution onrepsy-network). - (b) Hybrid (backend on host) — the common day-to-day dev setup:
repsy-backendruns from the IDE /mvn spring-boot:rundirectly on the host, and onlypostgres+repsy-scanner-trivyrun in the compose stack. The host-run backend needs its own env vars set directly, pointing at the stack's published ports:DB_URL=jdbc:postgresql://localhost:5432/repsy,SECURITY_SCANNER=enabled,TRIVY_SCANNER_BASE_URL=http://localhost:8090,TRIVY_SCANNER_API_KEY=<same value as the compose stack's TRIVY_SCANNER_API_KEY>, andDOCKER_INTERNAL_REGISTRY_BASE_URL=http://host.docker.internal:9090(this last one only resolves from insiderepsy-scanner-trivybecause of theextra_hostsentry above — without it, Linux Docker does not auto-maphost.docker.internalthe way Docker Desktop does on macOS/Windows).
See repsy-scanner-trivy/README.md for scanner service
details (standalone build/run instructions, its own environment variables, and API usage).
Manual Installation
Prerequisites:
- Java: JDK 25
- Spring Boot: 4.0.5
- PostgreSQL: 18 (unless you pass
DB_URLfor the embedded H2 database, see step 3) - Angular: 21
- Maven: 3.9.7 or higher
- Node.js 24.x (>=24.0.0 <25.0.0)
# 1. Build the backend
mvn clean install -DskipTests
# 2. Install frontend dependencies
cd repsy-frontend
pnpm install
cd ..
# 3. Run against PostgreSQL (see prerequisites above), or set DB_URL for a zero-dependency
# embedded H2 database instead, e.g.:
# DB_URL='jdbc:h2:file:/tmp/repsy;MODE=PostgreSQL;DB_CLOSE_DELAY=-1' \
# mvn spring-boot:run
cd repsy-backend
mvn spring-boot:run
Access at:
- Frontend (Web UI): http://localhost:4200
- Backend API: http://localhost:8080
- Repository Operations: http://localhost:9090
Backward incompatible changes in this release
This is the first release after v26.08.4. Read this section before you upgrade: some steps have to be done
before the new version starts for the first time.
Before you upgrade
- Back up the database and the storage directory. There is no downgrade. The upgrade runs database
migrations V0012 to V0031 that cannot be undone (they drop
repo.searchableand restructure the Docker manifest tables) and renames stored Docker manifest files.v26.08.4cannot run on the migrated data: to go back, restore the backup. (RPS-1216, RPS-1427) - Passwords keep working.
v26.08.4stores passwords with a salted SHA-256 hash. Repsy still verifies such a hash and replaces it with a BCrypt hash the first time its owner signs in (panel, or a package client with a username and password), so nobody has to reset a password and no client needs a new one. An account keeps its SHA-256 hash, and itsusers.saltvalue, until that first sign-in. A later release will retire this legacy check and announce how, so sign in once with every account you still need before you move to it. (RPS-1615) - Embedded H2 in
v26.08.4and earlier stops accepting writes after about 30 minutes; this release fixes it. See Embedded H2 stops accepting writes (releases up tov26.08.4) for who is affected and the workaround for an image you cannot replace yet. (RPS-1652, RPS-1676) - On the embedded H2 database, remove
;DB_CLOSE_ON_EXIT=FALSEfromDB_URLand give the container a stop grace period of 60 seconds or more. With the option a restart loses recent changes. See "Embedded H2 loses recent changes when Repsy stops" under Upgrading. (RPS-1857) DB_HOST,DB_PORTandDB_DATABASEare no longer read. OnlyDB_URLselects the database, and the Docker image now defaults it to an embedded H2 file. An installation that set only those three variables starts on a new, empty H2 database (the PostgreSQL data is untouched; aWARNis logged). SetDB_URL=jdbc:postgresql://<host>:<port>/<database>(withDB_USERNAMEandDB_PASSWORD). (RPS-1173, RPS-1423)- Artifact files in the Docker image moved to
/app/data/storage. Earlier images kept them in/home/appuser/.repsy, inside the container and not on the/app/datavolume, unless you setSTORAGE_BASE_PATHor mounted that directory. If you did neither, copy the files out of the old container before you replace it (docker cp repsy:/home/appuser/.repsy/. ./repsy-storage) and put them into/app/data/storageon the volume; replacing the container loses them. If you mount/home/appuser/.repsy, the new image keeps using it (and logs aWARN) until you move the files or setSTORAGE_BASE_PATHexplicitly. (RPS-1401) - Deleted items are now removed for good after 7 days, starting 15 minutes after the upgrade. A deleted
repository, package or version is moved to a
trash/directory, which nothing emptied until now. A daily job now deletes trash older thanTRASH_RETENTION(defaultP7D), and its first run deletes everything that has piled up. To keep the trash, setTRASH_CLEANUP_ENABLED=falseor a longerTRASH_RETENTION(for exampleP90D) before the first start. (RPS-1096)
Permissions
- Only
ADMINusers can create repositories (panel and API); aUSERgets403. (RPS-891, RPS-1268) - Removing stored files over the wire needs an
ADMINaccount: HelmDELETE /api/charts/<name>/<version>andnpm unpublish. A deploy token, read-write or read-only, is refused (401) for every such manage operation in every format.npm deprecate,npm dist-tag,cargo yank, NuGet unlist/relist andgem yankstill need only write access (andgem yankno longer needsADMIN). (RPS-1424, RPS-1317) - HTTP Basic realm renamed to
Repsy(wasRepsy Managed Repository,Repsy Managed RegistryorRepsy Go Module Proxy). Apache Ivy and sbt, which look credentials up by realm, needrealm="Repsy"inivysettings.xmlorCredentials("Repsy", host, user, password). (RPS-1372) - A credential in the
?token=query parameter is no longer accepted on the repository port. Send it in theAuthorizationheader. (RPS-1044) - Panel tokens and package-client tokens are no longer interchangeable. A token from the web UI login is refused
by the package endpoints and a token from
npm login,docker loginorcargo loginis refused by the panel API. (RPS-915)
Panel REST API (/api, marked beta) — scripts and generated clients
- Removed:
GET /api/repos/{type}/info,GET /api/repos/{type}/countandPOST /api/repos/{type}(now404). UseGET /api/repos?type=,GET /api/repos/countsandPOST /api/reposwithtypein the body; these accept a panel Bearer token only, not HTTP Basic. (RPS-1268) - Deploy token rotation moved from
PUT /api/repos/{repo}/deploy-tokens/{id}toPOST /api/repos/{repo}/deploy-tokens/{id}/actions/rotate. (RPS-1269) - npm scope lists moved to
GET /api/npm/scopes/{repo}/packagesandGET /api/npm/scopes/{repo}/{scope}/packages. (RPS-1010) - npm panel routes (RPS-1781): the
packageliteral between repo and name is gone (GET /api/npm/packages/{repo}/{package}/versionsand/tags), scoped packages moved to/api/npm/scopes/{repo}/{scope}/packages/{package}[/versions[/{version}]|/tags], andGET /api/npm/packages/{repo}/{package}is a package summary (scopeName,packageName,latestVersion,createdAt), no longer the latest version detail. Bodies are bare (nomsgId/dataenvelope) and the deletes answer204. - Docker panel routes (RPS-1781): bodies are bare and the deletes answer
204.GET /api/docker/images/{repo}/{image}is the image row (the same item the list shows);/summaryand the default-tag detail are gone. The cleanups moved toDELETE /api/repos/{repo}/docker/orphan-layers(204) andDELETE /api/repos/{repo}/docker/untagged-manifests(200,deletedManifestsandorphanLayersScheduled);/api/docker/images/blobs/...and/manifests/...are404. An image name with several segments (team/app) is passed as?image=team/appwith-as{image}. The manifest and config routes return the stored text as a JSON string. - Go panel routes (RPS-1781):
GET /api/go/modules/{repo}/searchis gone, use?q=onGET /api/go/modules/{repo}. Thesumdb/supportedpanel route is removed. Bodies are bare and the deletes answer204. - Every list takes its text filter as
q; the old names (name,query,search,version,groupName,artifactName) are ignored, so the list comes back unfiltered. Paging ispage,size(1 to 100; more is400) andsort(an unknown property is400). Cargo, NuGet and Ruby lists default to 10 per page (was 20). (RPS-1269, RPS-885, RPS-933) - Deploy token JSON is camelCase:
readOnly,expirationDate,createdAt(a body withread_onlyorexpiration_dateis400). The Cargo panel responses are camelCase too (maxVersion,updatedAt,packageName, ...). An expiration date more than 365 days ahead is400. (RPS-1269, RPS-1435) - Status codes: a failed login is
401(was403); a missing, invalid or expired token is401(was403); a signed-in user without theADMINrole is403 accessDenied(was401). (RPS-924, RPS-981, RPS-1284) GET /api/repos/{repo}/formatanswers the upper-case type (MAVEN, notmaven). (RPS-1269)- Repository settings:
releases/snapshotsare refused (400) for types other than Maven and NuGet, the PGP settings for types other than Maven, and a field left out ofPUT .../settingsnow keeps its value instead of being reset tofalse.searchableis gone. (RPS-1210, RPS-1188, RPS-1200, RPS-1427)
Behaviour changes
- Failed sign-ins are rate limited: 20 failed password or token checks per client address per minute, then
429 Too Many RequestswithRetry-After. Behind a reverse proxy, make it a trusted proxy that appendsX-Forwarded-For, or every client shares one limit. Tune withAUTH_THROTTLE_*. (RPS-1092, RPS-1209) - Sessions: everybody signs in to the web UI again once after the upgrade; a web UI session now ends after 24
hours at most; changing a password or username ends that user's
docker login,npm loginandcargo logintokens at once. (RPS-916, RPS-972, RPS-1552) - The panel API no longer allows any origin by default. With
APP_ALLOWED_ORIGINSunset, Repsy used to answer browser requests from any origin and allow credentials; unset now means same-origin only and the API sends no CORS headers. The Docker image as normally run (web UI and API on one origin,API_BASE_URLempty) is not affected. If the web UI is served from another origin than the API (a separately hosted web UI,API_BASE_URLpointing at another host, or the Angular dev server), setAPP_ALLOWED_ORIGINSto a comma-separated list of the exact origins of the web UI, for examplehttps://panel.example.com; only those origins are then allowed.mvn spring-boot:runallowshttp://localhost:4200through thedevprofile. (RPS-1590) - Browser security headers: the repository port (9090) sends no CORS headers any more, so a web page that reads
packages from it cross-origin stops working. The panel gets
X-Frame-Options: DENY(it cannot be embedded in a frame),Referrer-Policyand aContent-Security-Policy; every response getsX-Content-Type-Options: nosniff. If the panel'sAPI_BASE_URLpoints at another origin than the one serving the panel, setAPP_CSP_POLICY(orAPP_CSP_ENABLED=false). HSTS is opt-in (APP_HSTS_MAX_AGE). (RPS-1514, RPS-1131, RPS-1402) REPO_BASE_URLnow also sets the URLs the npm registry (dist.tarball) and the NuGet service index serve. Check that it is your public repository URL; when it is unset they are derived from the request (HostandX-Forwarded-*). (RPS-1333, RPS-1432)- Upload size limits: multipart uploads (PyPI, Helm, NuGet) up to 500 MB (
MULTIPART_MAX_*),gem push500 MB (RUBY_MAX_GEM_SIZE), Go module zips 500 MB (GO_MAX_MODULE_ZIP_SIZE), crates 100 MB (CARGO_MAX_CRATE_SIZE),npm publish500 MB (NPM_MAX_PUBLISH_SIZE); larger is413. Maven POM andmaven-metadata.xmlare limited to 10 MiB and a signature to 64 KiB. (RPS-1049, RPS-1055, RPS-1119, RPS-1121, RPS-1561) - Docker: manifests are stored by digest and a tag is a pointer. Re-pushing a tag or deleting a tag keeps the old
manifest (pullable by digest) and its layers on disk until you run Delete untagged manifests in the repository
settings, and an image whose last tag is deleted stays listed as "No tags". Old manifest files are renamed by a
background job 10 minutes after start.
DELETE /v2/<name>/manifests/<ref>is new and needs anADMINand a token with thedeletescope. (RPS-1216, RPS-1288, RPS-1434) - Maven uploads are checked more strictly: a path outside the Maven layout, a POM whose
groupIddiffers from its path, or an empty body is400(instead of200with nothing or an empty file stored); checksums follow the rules of their file; switching releases or snapshots off also refuses redeploys (403); a signature by an expired or revoked key is422;HEADanswers likeGET(404for a missing file). Repsy now serves a generatedmaven-metadata.xmlwhere none is stored and adds newly registered versions to a stored one. (RPS-1182, RPS-1193, RPS-1443, RPS-1183, RPS-1174, RPS-1202, RPS-1368, RPS-1369, RPS-1437) - npm: the
namein a publish body must match the URL (400); served packuments carry tarball URLs of this registry and no base64_attachments. (RPS-1207, RPS-1333, RPS-1390) - PyPI: an upload must carry a
sha256_digestthat matches the file (400otherwise). (RPS-1224, RPS-1225) - Go: module paths are case-sensitive; a version must be valid Go semver and the
go.modmodule line must match the URL (400);@v/listof a module without versions is404, sogotries the nextGOPROXYentry. (RPS-1232, RPS-1227, RPS-1228, RPS-1428) - NuGet:
1.0.0+buildis the same version as1.0.0(versions stored with build metadata are moved at startup; see README "Upgrading" for conflicts); search and autocomplete leave SemVer 2.0.0-only versions out unless the client sendssemVerLevel=2.0.0; a negativeskipis400andtakeis capped at 1000; a nuspec that is not well-formed XML or carries a DOCTYPE is400. (RPS-996, RPS-1059, RPS-1275, RPS-1120, RPS-1075) - Helm: an OCI push whose repository name differs from the chart name in
Chart.yamlis400; manifests stored under such a name are moved to the chart name at startup. (RPS-978, RPS-1038) - Ruby: a yanked version is left out of
/infobut its.gemstays downloadable. Cargo: crates are served under the name they were published with, index lines in publish order. (RPS-1235, RPS-1238, RPS-1212, RPS-1605) - A request that loses a concurrent update is answered
503withRetry-Afteron the repository port (clients retry). JSON on the repository port is gzip-compressed (SERVER_COMPRESSION_ENABLED=falseturns it off). (RPS-1355, RPS-1359) - New or renamed repositories cannot use a reserved name (
login,profile,repositories,users,security,not-found,api,assets,favicon.ico,counts,security-summary) or start with-; usernames an admin creates or renames must be lower-case (a-z,0-9,_,-). Existing names keep working. (RPS-1158, RPS-1268, RPS-1100, RPS-1037) - A user's password can be reset by creating a file named after the user in
/app/data/password-reset(enabled by default; anyone who can write to that directory can reset passwords;PASSWORD_RESET_MARKER_ENABLED=falseturns it off). (RPS-1107) - The vulnerability scanner is now published as
repo.repsy.io/repsy/os/repsy-scanner-trivywith the same tags as the application; run both images of the same release. (RPS-1400) repsy-scanner-trivydoes not start without aSCANNER_API_KEY(missing, blank or an unresolved${...}placeholder), and Repsy withSECURITY_SCANNER=enableddoes not start without aTRIVY_SCANNER_API_KEY; before, both started, the scanner accepting the literal text${SCANNER_API_KEY}as its key. Set the same secret on both, or leave the scanner disabled. (RPS-1663)- The upgrade jobs (Docker manifest rename, NuGet version move) log their progress at
INFO, which the default log level (WARN) hides; setLOGGING_LEVEL_IO_REPSY=INFOto follow them.
Upgrading
Embedded H2 stops accepting writes (releases up to v26.08.4) (RPS-1676)
Who is affected: every published release, v26.03.0 up to and including v26.08.4, that runs on the embedded H2 database. H2 was the default database of the Docker image until 26.08.0; v26.08.2 to v26.08.4 use it when DB_URL points to H2. Installations on PostgreSQL are not affected.
What happens: these releases ship H2 2.4.240, which has a bug (h2database/h2database#4308, #4320, #4342; fixed in H2 2.5.250): a CHECK (column IN (...)) constraint keeps the session that prepared it. The connection pool retires every connection after max-lifetime (30 minutes by default), and after that every insert or update of a table with such a constraint fails with Check constraint invalid ... The database has been closed. On the Docker image this shows up about 30 minutes after start: repositories cannot be created and logins cannot be recorded until the container is restarted.
Fixed in: the first release after v26.08.4, which ships H2 2.5.252 (H2CheckConstraintAcrossConnectionsTest pins it).
For an image you cannot replace yet: set SPRING_DATASOURCE_HIKARI_MAX_LIFETIME=2147483647 so that the pool does not retire connections. 2147483647 milliseconds is about 24.8 days, so restart the container at least that often. This was verified on the v26.08.4 image for a 200 second run (repository creation kept answering 200; with SPRING_DATASOURCE_HIKARI_MAX_LIFETIME=30000 the same probe failed with 500 from 20 seconds on); it was not run for the full 30 minutes. The reliable options are to upgrade or to use PostgreSQL. The embedded H2 database is meant for evaluation, so use PostgreSQL (DB_URL=jdbc:postgresql://<host>:<port>/<database>) for production.
Embedded H2 loses recent changes when Repsy stops (DB_CLOSE_ON_EXIT=FALSE) (RPS-1857 / RPS-1977)
Who is affected: an installation on the embedded H2 database whose DB_URL ends in ;DB_CLOSE_ON_EXIT=FALSE. That is the value the README listed as the H2 example up to v26.08.5, the default DB_URL of the Docker image of the releases that used H2 by default (up to 26.08.0) and the one the repsy-os Helm chart set in its H2 mode up to chart 26.08.5. Installations on PostgreSQL are not affected, and neither is an H2 DB_URL without that option.
What happens: with DB_CLOSE_ON_EXIT=FALSE H2 is never closed when the JVM exits, so whatever H2 still holds in memory is lost on an ordinary docker stop or restart: users created in the panel, the refresh token of a login, recent publishes. In a probe 11 of 11 recent changes were gone after a restart.
Fixed in: the first release after v26.08.5. The Docker image no longer sets the option, and Repsy runs CHECKPOINT SYNC when it stops, which also protects an installation that keeps the old DB_URL.
What to do now, on any release:
- Remove
;DB_CLOSE_ON_EXIT=FALSEfromDB_URL(for examplejdbc:h2:file:/app/data/repsy;MODE=PostgreSQL;DB_CLOSE_DELAY=-1). This works on the releases already published; no image change is needed. - Give the container a stop grace period well above Docker's default of 10 seconds, so that H2 can finish closing:
stop_grace_period: 120sin Compose,docker stop -t 120, orterminationGracePeriodSecondsin Kubernetes. A JVM that is killed before it has closed H2 counts as a crash. - A crash (a kill, a power loss) is still not safe on H2 2.5.250 and later: after an unclean stop the next start can lose changes that were committed earlier (RPS-1978). The embedded H2 database is meant for evaluation; use PostgreSQL for anything you cannot afford to lose.
Artifact storage moved to /app/data/storage in the Docker image (RPS-1401)
Earlier images did not set STORAGE_BASE_PATH, so artifacts went to /home/appuser/.repsy, inside the container's writable layer and not on the /app/data volume: a recreated container kept its database and lost every artifact. The image now defaults STORAGE_BASE_PATH to /app/data/storage, on the volume.
Who is affected: an existing container whose artifacts are in /home/appuser/.repsy (a bind mount of it, or a container that is only restarted, never recreated).
What happens: on startup entrypoint.sh keeps using /home/appuser/.repsy when STORAGE_BASE_PATH is still the image default, /app/data/storage is empty and /home/appuser/.repsy holds data. It logs a WARN: ... holds artifacts and /app/data/storage is empty line, so nothing disappears on upgrade. Without a mount of that directory, recreating the container has always lost the artifacts; nothing can restore them.
To move to the new default (once): stop Repsy, copy the content of /home/appuser/.repsy into /app/data/storage (for example docker cp repsy:/home/appuser/.repsy/. ./repsy-storage and then copy that directory into the volume with a helper container, or bind-mount your old directory at /app/data/storage), and start again. Or set STORAGE_BASE_PATH=/home/appuser/.repsy explicitly and keep mounting it.
DB_HOST, DB_PORT and DB_DATABASE are no longer read (RPS-1173 / RPS-1423)
Releases up to v26.08.4 built the PostgreSQL URL from DB_HOST, DB_PORT and DB_DATABASE. Only DB_URL is read now, and the Docker image defaults it to an embedded H2 file. A container that is upgraded with the old variables and no DB_URL therefore starts on a new, empty H2 database: the panel shows a fresh installation and the PostgreSQL data looks lost (it is untouched).
Set DB_URL=jdbc:postgresql://<host>:<port>/<database> instead (with DB_USERNAME and DB_PASSWORD). To help, Repsy logs a WARN at startup whenever any of the three variables is set, whatever DB_URL is:
The environment variables DB_HOST, DB_PORT are no longer read: only DB_URL selects the database. To keep using PostgreSQL set DB_URL=jdbc:postgresql://pg:5432/repsy (with DB_USERNAME and DB_PASSWORD).
When the effective database is H2 it adds that Repsy is starting on the embedded H2 database, not PostgreSQL. It never prints DB_URL itself, since that may carry credentials. Repsy still starts.
npm unpublish and Helm chart delete need the ADMIN role (RPS-1424)
Before this change, a USER account and a read-write deploy token could remove
stored files through two package clients although the web UI restricted that to ADMIN: npm unpublish (one version or a whole package) and deleting a Helm chart version with DELETE /api/charts/<name>/<version>. Both are manage operations now, like the web UI's delete and like
Docker's manifest delete: they need an ADMIN account, and a deploy token, read-write or read-only,
is refused for any manage operation in every format. See Repository Access.
Who is affected: a CI job that runs npm unpublish or deletes chart versions with a deploy token
or with a non-admin account. It now gets 401 and the command fails. Use an ADMIN account for
those jobs, or delete the version in the web UI.
What does not change: npm publish, npm deprecate, npm dist-tag add and rm, cargo yank,
NuGet unlist and relist, gem yank and publishing a Helm chart still need only write access, so a
read-write deploy token keeps running them.
Docker manifests are content-addressed (RPS-1216)
Docker manifests used to be stored as a child of a tag: pushing a tag again with a new manifest
rewrote the tag's manifest in place, so the previous manifest could no longer be pulled by its
digest (image@sha256:...). A manifest is now stored once per image and digest, and a tag is only a
pointer to it. Upgrading needs a database migration (V0024) and renames the stored manifest files.
Back up the database and the storage directory before upgrading. Flyway migrations are forward-only, and once the manifest files have been renamed the previous version can no longer read them: to go back, restore the backup. (The previous version cannot push against the migrated schema either.)
What the migration does (in one transaction, on PostgreSQL and on H2):
- one
docker_manifestrow per image and digest: the copies that older versions kept per tag (and the rows they added for the children of a multi-platform tag) are folded into the row of the original push; - each tag points to the row of its digest; the extra
sha256:...andsha512:...tags that a digest-pushed index or asha512push left behind are removed (their manifests stay, pullable by digest), and so is a tag that already answered404because its manifest row was gone; - a multi-platform tag's children are recorded as the index's references;
- rows of failed pushes that belonged to no tag are removed.
The file rename is done by a background job that starts 10 minutes after the application
(DOCKER_MANIFEST_LAYOUT_REPAIR_INITIAL_DELAY), reads every legacy manifest file, checks that its
bytes hash to the recorded digest, renames it to manifests/<digest> and records the sha512
digest. It is idempotent and resumable: a rerun (or a restart in the middle) finishes what is left.
Until a manifest has been renamed it is served from its old file name, so pulls keep working during
and after the upgrade. A manifest whose file is missing or does not match its digest is logged at
WARN and left exactly as it was. Progress and results are logged at INFO
(Docker manifest layout repair: ...).
NuGet versions with build metadata (RPS-996 / RPS-1059 / RPS-1123)
NuGet ignores build metadata when it compares versions: 1.0.0+a and 1.0.0 are the same version. The first release that contains RPS-996 (a version is stored, and found, by its version without build metadata), RPS-1059 (the migration below) and RPS-1123 (no lookup by the old spelling any more) stores a pushed 1.0.0+a as 1.0.0. The latest release, v26.08.4, contains none of these changes, so this applies starting with the next release.
What happens: versions that an earlier release stored with build metadata (1.0.0+a, in a directory of its own) are moved to their version without it (1.0.0) by a runner that starts with the application: it copies the files, renames the database row and the scans, and removes the old directory. It is idempotent, and a version that fails is retried on the next start. A database without such versions costs one query and logs nothing. Progress is logged at INFO (NuGet versions stored with build metadata: ...).
Conflicts: when 1.0.0 already exists next to 1.0.0+a the runner cannot decide which upload is the right one. It leaves both, logs each conflict (repository, package, version) at WARN on every start, and never drops one. Until you act, a request for 1.0.0+a is answered with the files of 1.0.0, and both still show in the version list.
To resolve a conflict: compare both uploads and then
- to keep
1.0.0and drop the other one, delete the1.0.0+aentry in the web UI. This removes only that entry and its directory; - to keep the other upload, delete
1.0.0in the web UI and restart Repsy: the runner then promotes1.0.0+ato1.0.0.
Deleting 1.0.0+a when there is no such entry (the usual case) still deletes 1.0.0, since they are one version. Releases before this change deleted 1.0.0 in both cases, so a conflict could not be cleaned up by deleting its entry.
Removal of the runner: it is kept in this release and in every release within about six months of it. It is removed in the first release after that (about 27.03.x), together with this section and with the sentence "Installations older than N must upgrade to N..27.02 first." (N is the release that first contains the runner, and 27.02 the last release that still has it). An installation that skips from an older release to one without the runner keeps its versions stored with build metadata: they are not served, and the panel shows them under their old spelling.
Configuration
Environment Variables
| Variable | Description | Default |
|---|---|---|
ADMIN_INITIAL_PASSWORD |
Initial admin password. Only applied on first startup when no admin exists. | (empty) |
DB_URL |
JDBC database URL. The Docker image defaults to the embedded H2 database; running from source (mvn spring-boot:run) defaults to PostgreSQL on localhost:5432 instead. DB_HOST, DB_PORT and DB_DATABASE are not read (Repsy warns at startup if they are set). On H2, a CHECKPOINT SYNC after every write (RPS-1556) keeps a crash right after a publish from losing the last commit even though the client was told it succeeded. |
jdbc:h2:file:/app/data/repsy;MODE=PostgreSQL;DB_CLOSE_DELAY=-1 (image only) |
DB_USERNAME |
Database username | repsy |
DB_PASSWORD |
Database password | repsy123 |
STORAGE_BASE_PATH |
Base directory for artifact file storage. Set to a path inside /app/data (e.g. /app/data/storage) to persist artifacts with a single volume mount (the Docker image already does). Deleting a repo, a package or a version moves its files into a trash/ directory of the protocol (for example maven/trash) first; the trash older than TRASH_RETENTION is removed every day (see TRASH_CLEANUP_ENABLED). |
~/.repsy (/app/data/storage in the Docker image) |
OS_APP_JWT_SECRET |
JWT signing secret. If not set, a random 256-bit secret is generated on every startup — every restart/redeploy invalidates all existing sessions, forcing every user to log in again. Set a stable, secure random value for any production/self-host deployment. | (random, regenerated on every startup) |
SERVER_PORT |
Repository operations port | 9090 |
API_PORT |
Backend API and Frontend web UI port | 8080 |
SERVER_COMPRESSION_ENABLED |
Gzip-compress JSON answers of 1 KB or more on the repository port (an npm packument of a package with many versions is megabytes of JSON). Set to false when a reverse proxy in front already compresses |
true |
H2_TCP_SERVER_ENABLED |
Enable the H2 TCP server. It binds loopback-only, so it is reachable only from inside the same container/host, not from an external client | false |
H2_TCP_SERVER_PORT |
H2 TCP server port | 9092 |
SECURITY_SCANNER |
Enables vulnerability scanning of pushed artifacts (enabled/disabled) |
disabled |
TRIVY_SCANNER_BASE_URL |
Base URL of the repsy-scanner-trivy service |
http://localhost:8090 |
TRIVY_SCANNER_API_KEY |
Shared API key sent to the scanner service (must match its SCANNER_API_KEY). Required when SECURITY_SCANNER=enabled: Repsy does not start with it blank or unset, and the scanner does not start without its own SCANNER_API_KEY. The scanner's own settings (SCANNER_API_KEY, TRIVY_TIMEOUT_SECONDS, TRIVY_DB_REPOSITORY, ...) are listed in repsy-scanner-trivy/README.md |
(none; unused while the scanner is disabled) |
DOCKER_INTERNAL_REGISTRY_BASE_URL |
Base URL the scanner uses to pull Docker images from this instance's own registry | http://localhost:9090 |
TRIVY_REQUEST_TIMEOUT_SECONDS |
How long Repsy waits on the scanner at one time: for its answer once a request has been sent in full, and for the scanner to accept more of an upload it has stopped reading. It does not limit how long an artifact takes to upload, so a large artifact (up to 500 MB) still scans on a slow link; a submit as a whole is cut off after TRIVY_MAX_SCAN_DURATION_SECONDS. A submit that fails on this timeout is not retried |
10 |
TRIVY_ADVISORY_LOOKUP_MAX_CONCURRENCY |
How many npm audit lookups (see Auditing npm packages) run on the scanner at once; an audit that finds no free place within TRIVY_ADVISORY_LOOKUP_MAX_WAIT_MILLIS is answered from the stored findings only. Each lookup is bounded by TRIVY_REQUEST_TIMEOUT_SECONDS and never delays a scan |
2 |
TRIVY_ADVISORY_LOOKUP_MAX_WAIT_MILLIS |
How long an npm audit waits for a free lookup place, in milliseconds | 2000 |
TRIVY_POLL_INTERVAL_MS |
How often Repsy asks the scanner for the status of an unfinished scan | 3000 |
TRIVY_MAX_SCAN_DURATION_SECONDS |
How long Repsy waits for a scan to finish, uploading the artifact to the scanner included, before it marks the scan failed | 330 |
TRIVY_SUBMIT_MAX_ATTEMPTS |
How many times Repsy submits a scan when it cannot reach the scanner (connection refused, DNS failure, connection reset, as while the scanner restarts), the first submit included. 1 turns the retry off. A scanner that answers with an error or does not answer in time is not retried; re-run that scan from the panel (1 to 5) |
3 |
TRIVY_SUBMIT_RETRY_INITIAL_DELAY_SECONDS |
Wait before the first retry of an unreachable scanner (1 to 300) | 15 |
TRIVY_SUBMIT_RETRY_MAX_DELAY_SECONDS |
Longest wait before any retry; the wait grows fourfold per retry (15 s, then 60 s). Keep the waits well below TRIVY_MAX_SCAN_DURATION_SECONDS, and note a pending retry is lost when Repsy restarts (the scan is then marked failed after that duration) (initial delay to 300) |
60 |
BASIC_AUTH_CACHE_ENABLED |
Remember successful HTTP Basic password checks, so a client that sends its username and password on every request pays for one password verification instead of one per request. See Authenticating from CI. | true |
BASIC_AUTH_CACHE_TTL_SECONDS |
How long a remembered password check stays valid | 300 |
BASIC_AUTH_CACHE_MAX_ENTRIES |
How many remembered password checks are kept | 10000 |
AUTH_THROTTLE_ENABLED |
Limit the failed password checks of one client (HTTP Basic, unrecognised Bearer tokens and web UI login), so a flood of wrong credentials cannot keep the CPU busy with password verification. A client over the limit is answered with 429 Too Many Requests. See Authenticating from CI. |
true |
AUTH_THROTTLE_MODE |
enforce, observe or off. In observe a client over the limit is logged at INFO and counted in the repsy.auth.throttle_would_block meter but never refused, to tune the limits before enforcing. AUTH_THROTTLE_ENABLED=false is the older spelling of off |
enforce |
AUTH_THROTTLE_MAX_FAILURES |
How many failed password checks one client may make per window before its next password check is refused | 20 |
AUTH_THROTTLE_WINDOW_SECONDS |
Length of the window in seconds. When it ends, the client starts again with a clean count | 60 |
AUTH_THROTTLE_MAX_CLIENTS |
How many clients are tracked at once | 10000 |
AUTH_THROTTLE_BAN_SECONDS |
How long a client that keeps guessing is refused outright (see AUTH_THROTTLE_BAN_AFTER_BLOCKED_WINDOWS). 0 turns the ban off. The ban is kept in memory, so a restart clears it |
3600 |
AUTH_THROTTLE_BAN_AFTER_BLOCKED_WINDOWS |
How many windows in a row a client must have used up its failed password checks in before it is banned | 3 |
PROBE_THROTTLE_MODE |
enforce, observe or off. Bans a client that sends probes: path traversal (../, also encoded), secret-hunting paths (.env, /proc/self/, .git/...), a NUL byte, or TRACE/PROPFIND on the API port, and, on the API port, requests for scripts the server does not have (.php), SQL injection, XSS, command injection and SSRF payloads, Log4Shell and template-injection probes, and known scanner User-Agents. A banned client is answered 429 with Retry-After on every path. In observe it is logged and counted in repsy.security.probe_would_block but never refused. A path traversal on the API port is answered 404 in every mode but off. Requests a browser marks Sec-Fetch-Site: cross-site or same-site are never counted, so another site cannot get a visitor banned. Bans are held in memory per instance (RPS-2319) |
enforce |
PROBE_THROTTLE_MAX_STRIKES |
Strikes that ban a client within one window. A traversal or secret path weighs 5, a NUL byte or abused method 3 | 10 |
PROBE_THROTTLE_WINDOW_SECONDS |
Length of the strike window in seconds | 60 |
PROBE_THROTTLE_BAN_SECONDS |
How long a banned client is refused | 3600 |
PROBE_THROTTLE_MAX_CLIENTS |
How many clients are tracked at once | 10000 |
PROBE_THROTTLE_ALLOWLIST |
Comma-separated addresses or CIDR ranges (10.0.0.0/8,192.0.2.5) that are never counted, for example an internal vulnerability scanner |
empty |
PROBE_THROTTLE_SIGNALS_<FAMILY>_ENABLED, ..._WEIGHT, ..._API_PORT_ONLY |
Switch off, reweigh or widen to the protocol port one signal family (weights in brackets): TRAVERSAL (5), SECRET_PATH (5), NULL_BYTE (3), METHOD_ABUSE (3), SCRIPT_TARGET (3, .php, wp-login, phpMyAdmin; not for a browser navigating and not under /api/, where stored names travel), SQLI (2, union select, sleep(), XSS (2), CMDI (2, query only), SSRF_URL (3, query only), LOG4SHELL (5, ${jndi:, every port), SSTI (2), DOUBLE_ENCODED (1), OVERLONG_URI (1, over 2048 characters), SCANNER_UA (3, a known scanner's User-Agent; never bans alone). All but TRAVERSAL, NULL_BYTE, LOG4SHELL and SCANNER_UA are API port only by default |
true, the weights given |
PASSWORD_RESET_MARKER_ENABLED |
Reset a user's password when a file named after the user is created in PASSWORD_RESET_MARKER_DIR. Nothing is reachable over the network: it takes write access to that directory. See Forgot admin password?. Set it to false to switch the feature off |
true |
PASSWORD_RESET_MARKER_DIR |
Directory watched for password reset marker files. The Docker image sets it to /app/data/password-reset, on the persisted volume, whatever STORAGE_BASE_PATH is |
<STORAGE_BASE_PATH>/password-reset (/app/data/password-reset in the image) |
PASSWORD_RESET_MARKER_POLL_INTERVAL |
How often a running instance looks into the directory (ISO-8601 duration, at least PT1S). A marker created while the application is stopped is applied once at startup |
PT5S |
ABANDONED_UPLOAD_CLEANUP_ENABLED |
Periodically delete Docker and Helm OCI blob uploads that were started and never finished (aborted pushes), and release the disk usage they were charged for | true |
ABANDONED_UPLOAD_TTL |
How long an upload can go without receiving data before it counts as abandoned (ISO-8601 duration) | PT24H |
ABANDONED_UPLOAD_CLEANUP_INTERVAL |
How often the cleanup runs (ISO-8601 duration) | PT1H |
ABANDONED_UPLOAD_CLEANUP_INITIAL_DELAY |
How long after startup the first cleanup runs (ISO-8601 duration) | PT10M |
PENDING_SIGNATURE_TTL |
How long a Maven signature that arrived before the file it signs is held (ISO-8601 duration, see Signed Maven Deploys); older ones are deleted | PT24H |
PENDING_SIGNATURE_PURGE_INTERVAL |
How often the held signatures older than PENDING_SIGNATURE_TTL are deleted (ISO-8601 duration) |
PT15M |
PENDING_SIGNATURE_PURGE_ENABLED |
Delete the expired held Maven signatures; set to false to keep them |
true |
TRASH_CLEANUP_ENABLED |
Periodically empty the storage trash: what you delete (a repo, a package or a version) is moved into a trash/ directory of its protocol first, and this job removes it from the disk for good once it is older than TRASH_RETENTION. The first run after an upgrade deletes all the trash older than TRASH_RETENTION that has piled up so far, and that cannot be undone. Set it to false to keep the trash |
true |
TRASH_RETENTION |
How long deleted items stay in the trash before they are removed for good (ISO-8601 duration, at least P1D; a shorter value stops the application from starting). Raise it to keep deleted items recoverable for longer |
P7D |
TRASH_CLEANUP_INTERVAL |
How often the trash is emptied (ISO-8601 duration) | PT24H |
TRASH_CLEANUP_INITIAL_DELAY |
How long after startup the first trash cleanup runs (ISO-8601 duration) | PT15M |
DOCKER_MANIFEST_LAYOUT_REPAIR_ENABLED |
After upgrading past RPS-1216, rename the Docker manifest files of earlier versions (named after the tag they were pushed under) to manifests/<digest> and record their sha512 digest. Until a manifest is repaired it is served from its old file name, so switching the job off only delays the cleanup. See Upgrading |
true |
DOCKER_MANIFEST_LAYOUT_REPAIR_INITIAL_DELAY |
How long after startup the first repair pass runs (ISO-8601 duration) | PT10M |
DOCKER_MANIFEST_LAYOUT_REPAIR_INTERVAL |
How often the repair pass runs again (ISO-8601 duration); once nothing is left to repair it costs one query | PT24H |
MULTIPART_MAX_FILE_SIZE |
Largest single file a multipart upload may carry: the package archive of a PyPI (twine upload), Helm (POST /{repo}/api/charts) or NuGet push. A larger upload is answered with 413. Accepts a size such as 100MB or 1GB. A Helm chart is copied to a temporary file (in java.io.tmpdir) while it is checked and stored, not held in memory, so keep that directory on a disk with room for the largest chart |
500MB |
MULTIPART_MAX_REQUEST_SIZE |
Largest total size of a multipart request, all parts included. Keep it at least as large as MULTIPART_MAX_FILE_SIZE |
500MB |
RUBY_MAX_GEM_SIZE |
Largest gem a gem push may carry (the raw request body, so the multipart limits do not apply to it). A larger gem is answered with 413. The gem is copied to a temporary file (in java.io.tmpdir) while it is checked and stored, not held in memory. Accepts a size such as 100MB or 1GB |
500MB |
CARGO_MAX_CRATE_SIZE |
Largest .crate a cargo publish may carry (a length-prefixed field inside Cargo's own wire format, so neither the multipart limits nor MULTIPART_MAX_FILE_SIZE apply to it). A larger crate is answered with 413. The crate is copied to a temporary file (in java.io.tmpdir) while it is checked and stored, not held in memory. crates.io itself defaults to 10MB; raise this if you publish larger internal crates. Accepts a size such as 100MB or 1GB |
100MB |
NPM_MAX_PUBLISH_SIZE |
Largest npm publish body a client may send: the packument plus its base64-encoded tarball (the raw request body, so the multipart limits do not apply to it), roughly 4/3 of the tarball's own size. A larger publish is answered with 413, checked from Content-Length and again while the body is read. Accepts a size such as 100MB or 1GB |
500MB |
APP_ALLOWED_ORIGINS |
Comma-separated list of exact origins (e.g. https://panel.example.com,https://panel-staging.example.com) the panel API accepts cross-origin, credentialed requests from. Unset (the default) means same-origin only: the panel API sends no CORS headers, which is what the Docker image needs, since it serves the panel and the API from one origin. Set it when the panel is served from a different origin than the API (a separately hosted SPA, or API_BASE_URL pointing elsewhere). The CSP connect-src names the same origins. See Cross-Origin Requests (CORS) |
(empty, same-origin only) |
APP_HSTS_MAX_AGE |
max-age in seconds of a Strict-Transport-Security header. 0 or unset never sends it (the default). When positive, it is sent only on a secure request (a TLS listener, or a reverse proxy that forwards X-Forwarded-Proto: https), on both ports. HSTS applies to a whole host, not a port, so leave it off when the same host also serves the panel over plain HTTP (for example :8080 plain beside :8443 TLS). See Security headers |
0 (off) |
APP_CSP_ENABLED |
Send a Content-Security-Policy header with the panel SPA and its static assets (JSON API responses are unaffected). See Content Security Policy |
true |
APP_CSP_REPORT_ONLY |
Send Content-Security-Policy-Report-Only instead of the enforcing header: violations are reported (in a browser that supports the Reporting API and is told where to send reports), nothing is blocked. Useful while rolling out a widened or replaced policy |
false |
APP_CSP_POLICY |
Overrides the built-in Content-Security-Policy outright, so an operator can widen it (for example to allow a CDN or font host) without a rebuild. See Content Security Policy for the built-in policy | (empty, built-in policy) |
Important Notes:
- Admin Username:
admin - Admin Initial Password: Only applied when no admin user exists in the database. After first run, change your password through the application interface.
- OS_APP_JWT_SECRET: If left unset, a new random secret is generated in memory on every container start — since it isn't persisted, this means every restart or redeploy silently invalidates every issued access/refresh token, logging every user out at once. For any production or self-host deployment, set this to a fixed, securely generated value (e.g.
openssl rand -base64 32) and keep it unchanged across restarts.
Content Security Policy
Repsy sends a Content-Security-Policy header with the panel SPA (GET /, every deep SPA route)
and its static assets (JS/CSS bundles, index.html). It is not sent with JSON API responses
(/api/**), which have nothing to enforce a policy against. This is a second barrier against a
sanitiser bypass in the README viewer or any future use of raw HTML injection in the panel: even if
one slips through, the browser itself refuses to load or run content the policy does not allow.
The built-in policy:
default-src 'self';
script-src 'self';
style-src 'self' 'unsafe-inline';
font-src 'self' data:;
img-src 'self' data:;
connect-src 'self' <app.allowed-origins>;
object-src 'none';
base-uri 'self';
frame-ancestors 'none';
form-action 'self' http://127.0.0.1:*;
The panel names no third-party host: it loads no analytics, tag manager, CDN stylesheet, web font
or avatar image from another origin, so it works with no outbound internet access.
connect-src additionally allows whatever origins APP_ALLOWED_ORIGINS allows (see
Cross-Origin Requests (CORS)), since a browser calling the API
cross-origin from one of those origins is exactly what CORS was configured to allow.
form-action additionally allows http://127.0.0.1:*: the /cli/auth page posts a new access token to
the Repsy CLI listening on the user's own machine. If you replace the policy with APP_CSP_POLICY, keep that
source in form-action, or that sign-in cannot hand the token to the CLI (the page then offers the
copy-and-paste path). A CSP cannot name the IPv6 loopback [::1], so a CLI that listens there is not reached.
- Set
APP_CSP_REPORT_ONLY=trueto sendContent-Security-Policy-Report-Onlyinstead while rolling a change out: violations are reported, nothing is blocked. - Set
APP_CSP_POLICYto replace the built-in policy outright, for example to allow a CDN or font host, without a rebuild. - Set
APP_CSP_ENABLED=falseto turn the header off entirely (for example if a reverse proxy in front of Repsy already sends its own).
Cross-Origin Requests (CORS)
By default the panel API is same-origin only: it sends no CORS headers, so a browser refuses to let a
page on another origin read its responses. That is what the Docker image needs, since it serves the
panel and the API from one origin (API_BASE_URL is empty).
Set APP_ALLOWED_ORIGINS to a comma-separated list of exact origins (for example
https://panel.example.com) when the panel is served from a different origin than the API: a
separately hosted single-page app, or the Docker image started with API_BASE_URL pointing at
another host. Exactly those origins are then allowed, with credentials; a request or preflight from
any other origin is rejected with 403. Before this default changed, an unset variable reflected any
origin with credentials.
For local development, mvn spring-boot:run activates the dev profile
(application-dev.yml), which sets APP_ALLOWED_ORIGINS to http://localhost:4200, the Angular dev
server (pnpm start), unless you export the variable yourself. A packaged jar or image never
activates it.
This applies to the panel API port only. The repository port (9090, the one mvn, npm, docker
and the other package-manager clients talk to) sends no CORS headers at all: no browser calls it
cross-origin and the clients never send an Origin, so nothing is answered there whatever
APP_ALLOWED_ORIGINS says.
Security headers
Besides the Content Security Policy, Repsy sends these browser-facing headers:
| Header | Where |
|---|---|
X-Content-Type-Options: nosniff |
Every response, on both ports (the repository port serves user-uploaded files) |
Referrer-Policy: strict-origin-when-cross-origin |
Every response on the panel API port |
X-Frame-Options: DENY |
Every response on the panel API port (the legacy twin of the CSP's frame-ancestors 'none') |
Strict-Transport-Security: max-age=<APP_HSTS_MAX_AGE> |
Only when APP_HSTS_MAX_AGE is set to a positive number of seconds, and only on a secure request |
HSTS is opt-in because it is scoped to a host, not to a port: a browser that has seen it for a host
upgrades http://host:8080 to HTTPS too, which would break the documented setup that serves the
panel on plain :8080 and on TLS :8443 of one host. Set it when everything on the host is HTTPS
only; a reverse proxy in front of Repsy normally owns it, and then it needs no setting here.
Reverse Proxy
Repsy can run behind a reverse proxy (nginx, Traefik, Caddy, etc.) on a different public URL. The backend enables Spring Boot's forward-headers-strategy: native, which reads the X-Forwarded-Proto, X-Forwarded-Host, and X-Forwarded-Port headers to resolve the correct scheme/host/port instead of the internal localhost:8080/:9090.
For this to work, your proxy must forward these headers. Example nginx config:
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
Client address and the failed-login limit. The failed-login limit counts per client address, and behind a proxy that address is the one your proxy puts in X-Forwarded-For. Tomcat honours that header only when the connection comes from a trusted proxy: by default every private and loopback address (server.tomcat.remoteip.internal-proxies, for example SERVER_TOMCAT_REMOTEIP_INTERNAL_PROXIES as an environment variable). A client that connects directly from a public address cannot choose its own address with the header. Two things have to hold behind your proxy:
- The proxy must append the connecting client to
X-Forwarded-For($proxy_add_x_forwarded_forin nginx), not forward the header as it received it, or a client could pick its own address and never be limited. - The proxy must be a trusted proxy. Otherwise every request appears to come from the proxy's own address, all clients share one count, and one client sending wrong passwords could get everybody refused for the rest of the window.
If that happens, Repsy fails safe: the limit is generous (20 failures in 60 seconds), remembered passwords and deploy tokens keep working, and each blocked address is logged once per window at INFO level (Client <address> made N failed password checks ...), so an address that turns out to be your proxy tells you what to fix. AUTH_THROTTLE_ENABLED=false turns the limit off if you already rate-limit failed logins in the proxy.
Note: The web UI's "how to connect" config snippets for repository operations (Maven, npm, pip, etc.) use the
REPO_BASE_URLenvironment variable, resolved at container startup — set it to your public repository-operations URL (e.g.https://repo.example.com) when running behind a reverse proxy.
npm tarball URLs. The npm registry names itself in the dist.tarball of every version it serves, whatever host, port or scheme the publisher used (the npm CLI and Yarn 1 write http:// even for an HTTPS registry). It takes the address from REPO_BASE_URL (the repsy.npm.public-url property) when that is set, and otherwise from each request (Host and the X-Forwarded-* headers above). Set REPO_BASE_URL when your proxy strips a path prefix or cannot send those headers, and when the proxy is not a trusted one, because the address cannot be derived from the request there. Nothing has to be migrated: versions published earlier are served with the new address too.
NuGet resource URLs. The NuGet service index (/v3/index.json), the registration (including the packageContent URL of each .nupkg) and the search results name their URLs with an address, and dotnet restore follows them. Like npm, NuGet takes that address from REPO_BASE_URL (the repsy.nuget.public-url property) when that is set, followed by the repository name (https://repo.example.com/my-repo/v3/index.json), and otherwise from each request (Host and the X-Forwarded-* headers above). Set REPO_BASE_URL when your proxy strips a path prefix or cannot send those headers, so that clients are not sent to your internal address.
Usage
First Login
- Navigate to http://localhost:8080
- Login with:
- Username:
admin - Password: the value you set for
ADMIN_INITIAL_PASSWORD
- Username:
Username Casing
Usernames are case-sensitive. Users must use the exact casing chosen when the account was
created when signing in or changing a username; for example, MixedCase123, mixedcase123, and
MIXEDCASE123 are distinct usernames. Username uniqueness is also checked with exact casing, so
different casing variants may be registered separately.
The admin user search is case-insensitive for convenience, so a search result may not be usable for login unless its displayed casing is entered exactly.
Repository Access
Repsy has no per-repository owners or access lists. What a caller may do depends only on whether they are signed in, and on their role:
| Caller | Public repository | Private repository |
|---|---|---|
| Anonymous | Read | No access |
Signed-in USER |
Read and write | Read and write |
ADMIN |
Everything a USER can do, plus manage |
Everything a USER can do, plus manage |
"Private" therefore means login required, not "restricted to specific users". Every user account
on the instance can read and modify every repository, including private ones, and can see their
names. Only manage operations need the ADMIN role: creating a repository, renaming it or
changing its description and settings, deleting it, deleting its artifacts and versions, managing
its deploy tokens, and managing users.
The web UI and the package clients follow the same rule (RPS-1424). What removes stored files is a
manage operation on the wire too: npm unpublish (one version or the whole package) and deleting a
Helm chart version with DELETE /api/charts/<name>/<version> need the ADMIN role, and so does
deleting a Docker manifest or tag. A USER account and a deploy token are refused with 401 and a
challenge, as for any credential a package client is not allowed to use. Operations that only change what a repository
advertises, and keep every file, need write access: npm deprecate, npm dist-tag add and rm,
cargo yank, NuGet unlist and relist, and gem yank.
Deploy tokens are scoped to a single repository, so use one to give a CI job or an external party access to that repository without a user account. A deploy token reads and, unless it is read-only, writes; it never manages, so a CI credential can publish but cannot delete what it published. Only create user accounts for people you trust with every repository on the instance; to keep repositories apart between teams, run one Repsy instance per team.
Docker Registry Semantics
- Manifests are content-addressed. A manifest is stored once per image and digest and stays
pullable by that digest,
docker pull repo/image@sha256:..., whatever happens to the tags that point at it. The registry stores thesha256and thesha512digest of every manifest, so it is addressable by either, and it answers in the algorithm the client used: a reference bysha512gets asha512Docker-Content-Digestback (on push,GETandHEAD, and in the push'sLocation), a tag or asha256reference thesha256one. A manifest pushed by a digest never becomes a tag, and asha512reference that is not the manifest's own digest is refused with400 DIGEST_INVALID. A manifest written by an earlier version is addressable by itssha512once the repair job below has recorded it (or the same bytes are pushed again). - A tag is a movable pointer. Pushing a tag again with a different manifest (when the repository allows overriding) moves the pointer; the manifest it pointed at before stays stored and pullable by its digest. Pushing the manifest a tag already points at changes nothing. Deleting a tag in the web UI removes the pointer only, in the same way.
- An image lives as long as it stores a manifest. Deleting the last tag of an image does not
delete the image: its manifests stay pullable by digest, so the web UI keeps listing it as "No
tags", with how many untagged manifests it still stores and their size, and its page offers
"Delete untagged manifests" and "Delete image". The image is removed automatically when its last
manifest goes, whether by a protocol
DELETEby digest or by "Delete untagged manifests"; the next push of that name creates it again. - Untagged manifests accumulate. Nothing deletes a manifest automatically, so every override
and every deleted tag leaves the previous manifest, and the layers only it used, on disk and in
the repository's usage until you remove them with "Delete untagged manifests" in the
repository settings, or one at a time with a protocol
DELETEby digest (below). Deleting a whole image removes its manifests too (a manifest file that another image of the repository shares is kept until the last image that has it is gone). - "Delete untagged manifests" (repository settings, needs the manage permission; API:
DELETE /api/docker/images/manifests/{repoName}/untagged, optionally?image=<name>) deletes every manifest that no tag points to, directly or through a tag's index, together with its file, and refunds the disk usage right away. Those manifests stop being pullable by digest. It then deletes the layers that no manifest uses any more, which is what actually frees the space: the manifest files themselves are only kilobytes. The layer blobs are deleted in the background, and the usage drops as they go. Run it when nobody is pushing to the repository: a manifest pushed by digest whose tag or index has not arrived yet counts as untagged, and a client pushing an index right after would have to push that manifest again. - "Delete orphan layers" in the repository settings deletes the layer blobs that no manifest uses (for example, left by a refused push). It does not touch manifests, so it frees nothing that an untagged manifest still uses: use "Delete untagged manifests" first, which runs this sweep itself, and "Delete orphan layers" for blobs no manifest ever used.
DELETE /v2/<name>/manifests/<reference>needs the MANAGE permission (an admin user, the same as the operations of the repository settings page). A client that deletes manifests, such ascrane delete, works with the credentials it was logged in with; a deploy token, which only reads and writes, is refused with401, and so is a user without the admin role.- By digest (
sha256:orsha512:) it deletes the manifest and every tag that points at it, so neither the digest nor those tags can be pulled afterwards, as the distribution specification describes. It answers202 Accepted; a digest the image does not have is404 MANIFEST_UNKNOWN(a malformed one is400 DIGEST_INVALID). The manifests an index lists are not deleted with it: another index may still list them, so they stay, untagged, until they are deleted by their own digest or by "Delete untagged manifests". The manifest file is deleted (and its bytes released from the repository's usage) unless another image of the repository has the same manifest. - By tag it deletes that tag only, like deleting a tag in the web UI: the manifest stays
pullable by its digest. It answers
202 Accepted; an unknown tag is404 MANIFEST_UNKNOWN. - The layers a deleted manifest used are not deleted with it: "Delete orphan layers" (or "Delete untagged manifests", which runs that sweep) removes the ones no manifest uses any more.
- By digest (
- There is no
tags/listor referrers API yet.
Go Module Semantics
- A module lasts as long as it has a version. Deleting the last version of a Go module in the
web UI (or with
DELETE /api/go/modules/{repoName}/versions) also deletes the module: it leaves the module list and its stored files are moved to the trash, like the version's own. Publishing a version of that path again creates the module again. Deleting a module as a whole is the same operation for all of its versions. The disk usage of what was deleted is given back to the repository, and a deleted version is reported to the vulnerability scanner as deleted. - The Go proxy answers as it does for a module that was never published.
@v/listand@latestof a module without versions are404with atext/plainbody, so thegocommand tries the nextGOPROXYentry instead of taking an empty list as an answer. - A delete and a publish of the same module take turns, so a publish that arrives while the last version is being deleted is stored, in a module that is created again, and never fails.
Signed Maven Deploys
A Maven repository's key store (panel API, /api/mvn/key-stores/{repoName}/public-keys) can hold
armored OpenPGP public keys directly, in addition to the key-server hosts it already supports. When
a .pom.asc signature is verified, its registered public keys are consulted first, before any key
server. This lets a signature made with a key that is never published to a public server — a
company-internal key, a CI key, a freshly generated key — verify without network access. A
signature whose key is neither registered nor found on any allowed or default key server is
refused with 404 and nothing is stored. A key block a key server answered is remembered for ten
minutes, so a deploy with many signatures by one key asks the server once.
Two per-repository settings (repository settings page, or PUT /api/repos/{repoName}/settings
with pgpVerifyAllSignaturesEnabled / pgpKeyServerLookupEnabled; Maven repositories only) change
what is verified and where keys are looked up:
- Default (
pgpVerifyAllSignaturesEnabledoff): only the.pom.ascis verified. Any other signature (.jar.asc,-sources.jar.asc,.module.asc, ...) is stored as it is sent, and a version shows Signed when its POM signature verified. - Verify every signature (
pgpVerifyAllSignaturesEnabledon): every artifact.ascis verified against the file it signs before it is stored, exactly like the.pom.asc: a signature that does not match answers422and stores nothing. A version then shows Signed only when every file of it that a signing tool signs (the POM, the jar, every classifier jar, the.module, ... but not checksums, signatures ormaven-metadata.xml) has a verified signature, so a partly signed release stays Unsigned. Files and signatures may arrive in any order, which a realmvn deployneeds: Maven uploads the files of a deploy in parallel, so the signature of a large file can reach Repsy before the file, or before the POM that registers the version. Such a signature is answered200and held: it is not stored, not served (GETanswers404) and not charged, and it is verified when the file it signs arrives (or when the POM registers the version), then stored and recorded. If it does not verify, the file's upload fails with422 pendingSignatureNotVerified(a new file is taken back out of the repository, and the held signature is dropped so an upload of both again starts clean); if the signer's key cannot be found then, it fails with404and the signature stays held. A held signature that no file claims is deleted afterPENDING_SIGNATURE_TTL(PT24H, see the configuration table); a re-sent signature replaces the one that is held. A signature that is not an OpenPGP signature at all is still refused at once with422 artifactSignatureNotVerified. A repository that does not verify every signature holds nothing: there a.pom.ascbefore its POM is refused with404as before. Uploading a new file, or storing a file again (with Allow override), makes the version Unsigned until that file's signature is uploaded and verified. For a snapshot only the files of its newest build count. Turning the setting on or off recomputes Signed of every existing version of the repository in the background, under the rule of the new setting (with it off, a version is Signed when its POM signature is verified; with it on, when every file has a verified signature). The settings request does not wait for it, so a large repository shows the new values within moments, not at once. A signature that was stored while the setting was off was never verified, so turning the setting on verifies it then, in that same background run, file by file and with the same key rules as an upload (registered keys first, key servers if the lookup is on): an honest publisher's versions stay Signed. A stored signature that does not verify, or whose key cannot be found, does not count and its version shows Unsigned until the file and signature are uploaded again (or the key is registered and the setting turned off and on again). Turning it off leaves held signatures alone: they are deleted when they expire. - Air-gapped registries (
pgpKeyServerLookupEnabledoff): the repository consults its registered keys only. A signature made with a key that is not registered is refused at once with404 artifactSigningKeyNotRegistered, without contacting any key server (custom hosts,keyserver.ubuntu.comorkeys.openpgp.org), so no network call and no timeout wait. Defaults to on.
Maven Allow override and SNAPSHOTs
With Allow override off, a Maven repository refuses to store a file that already exists
(403 artifactOverrideIsProhibited), with one exception: a SNAPSHOT can always be deployed again.
- Releases are immutable. Any file of a release version that already exists is refused.
- A timestamped SNAPSHOT build is immutable.
mvn deployand Gradle write a new build (lib-1.0-20260921.101010-2.jar) on every deploy, so a redeploy never touches an existing file, and a file of an existing build is refused like a release file. - A non-unique SNAPSHOT is replaced. sbt and Apache Ivy deploy
lib-1.0-SNAPSHOT.pom/.jar(and their checksums, and classifier jars) under those literal names every time, so a repeated deploy replaces the files instead of adding a build. That is accepted, as it is on Nexus and Artifactory, and it is what makes the setting behave the same for every client. - The Snapshots switch of the repository still decides: with it off, a SNAPSHOT is refused in both forms.
The panel shows the version of a SNAPSHOT that carries no maven-metadata.xml (what sbt and Ivy
leave) with the newest POM stored for it: the POM of the newest timestamped build, or the literal
-SNAPSHOT POM when there is no build. The same goes for a maven-metadata.xml that cannot be
parsed, and a manual vulnerability scan of such a SNAPSHOT scans its newest stored jar.
Apache Ivy Clients
The web UI's Maven configuration dialog shows this setup with your repository URL and username filled
in. Apache Ivy reads a Maven repository through an ibiblio resolver in Maven-compatible mode; put
this in ivysettings.xml (repo.example.com is your REPO_BASE_URL host, my-repo the repository):
<ivysettings>
<settings defaultResolver="repsy"/>
<credentials host="repo.example.com"
realm="Repsy"
username="YOUR_USERNAME"
passwd="YOUR_PASSWORD"/>
<resolvers>
<ibiblio name="repsy" m2compatible="true" root="https://repo.example.com/my-repo/"/>
</resolvers>
</ivysettings>
Credentials: Ivy looks them up by host (without the port) and realm. Repsy challenges with
Basic realm="Repsy", so setrealm="Repsy": a<credentials>without a realm (or with another one) sends no credentials and every request is answered401. Earlier versions used a longer realm name: if yourivysettings.xmlstill names that, change it toRepsy(the same goes for the realm in sbt'sCredentials(realm, host, user, password)). With a deploy token, use it aspasswd; theusernamecan be empty.Publishing needs a POM. A version is registered (and shows up in the web UI) by its POM, so list a
pomartifact next to the jar in the<publications>of yourivy.xmland create it withivy:makepom:<ivy-module version="2.0"> <info organisation="com.example" module="my-lib" revision="1.0.0"/> <configurations> <conf name="default"/> </configurations> <publications> <artifact name="my-lib" type="jar" ext="jar" conf="default"/> <artifact name="my-lib" type="pom" ext="pom" conf="default"/> </publications> </ivy-module><project name="my-lib" xmlns:ivy="antlib:org.apache.ivy.ant"> <target name="publish"> <ivy:settings file="ivysettings.xml"/> <ivy:resolve file="ivy.xml"/> <ivy:makepom ivyfile="ivy.xml" pomfile="build/my-lib.pom"/> <ivy:publish resolver="repsy" pubrevision="1.0.0" publishivy="false"> <artifacts pattern="build/[artifact].[ext]"/> </ivy:publish> </target> </project>publishivy="false"onivy:publish(it is an attribute of the task, not of the resolver). Otherwise Ivy also uploads its own ivy file, asivy-<revision>.xmlafter the jar and the POM, which Repsy refuses with400 invalidArtifactPath: the build fails, although the jar and the POM are already stored.Dependencies: if the module has
<dependencies>, giveivy:makepoma<mapping conf="default" scope="compile"/>(a child element of the task). Without a mapping every dependency is written to the POM as<optional>true</optional>, and a consumer does not resolve it transitively.Republishing: Ivy's
overwritedefaults tofalse, so Ivy itself refuses to publish over a file that already exists ("destination file exists and overwrite == false"). Setoverwrite="true"onivy:publishto republish a SNAPSHOT; for a release, Repsy's Allow override setting still decides.Reading: the same
ivysettings.xmlresolves dependencies. A dependency on an artifact published like this can use thedefaultconfiguration (conf="default->default"), as the dependency line on a version's page in the panel does, which asks for the jar only; a dependency without aconfresolves as well, because Repsy answers404for thesourcesandjavadocartifacts the artifact does not have and Ivy then skips them.maven-metadata.xml: Ivy, sbt and a rawPUTpublish none, so there is no file to store. Repsy answers aGETorHEADof the artifact-level<group path>/<artifact>/maven-metadata.xml(and of its.md5,.sha1,.sha256and.sha512) from the versions it has registered when no client stored one, so MavenLATEST,RELEASEand version ranges, Gradle1.+and sbtlatest.releaseresolve for such artifacts. A file a client did store is served as it is, and it is kept complete: when a POM registers a version that the stored file lacks (an artifact thatmvn deployor Gradle published first and that Ivy or sbt then adds a version to), Repsy adds the version to the file, sorts the versions, recomputeslatest,releaseandlastUpdated(latestis the highest version, not the last one deployed), rewrites the checksums that are stored next to it and deletes a storedmaven-metadata.xml.asc, which no longer verifies. It only ever adds: a version the file lists that Repsy does not know is kept, and a file that cannot be parsed is left untouched. A latermvn deployof the artifact finds the file and stores it with its own version added. Two clients publishing the same artifact at the same moment can still lose a version, when a client uploads a file it computed from a copy read before the other's version was added; the next POM registered for the artifact lists it again. Maven's plugin prefix lookup (mvn hello:hi, with the plugin's group inpluginGroups) reads the group-level<group path>/maven-metadata.xmlinstead, whichmvn deployuploads for a plugin but Gradle'smaven-publish, sbt, Ivy and a rawPUTdo not. Repsy answers aGETorHEADof that file (and of its four checksums) too, from the plugins it has registered for the group, when none is stored: a<plugins>list with each plugin's name, prefix and artifactId, ordered by artifactId. The prefix is the plugin's owngoalPrefix, which its jar names inMETA-INF/maven/plugin.xml, when the jar is stored before the POM (the order of Gradle'smaven-publish;mvn deploystores the file with the real prefix itself), and otherwise the onemaven-plugin-pluginderives from the artifactId (hello-maven-pluginandmaven-hello-plugingivehello,maven-plugin-plugingivesplugin). It is read once, when the POM registers, so a client that sends the POM before the jar gets the derived prefix until it uploads the POM again; the prefix of the artifact is that of its latest POM. A jar that cannot be read, has no descriptor, describes another artifact or names something that is not a usable prefix falls back to the derived one and never fails the upload. A stored group-level file is served as it is, and it is kept complete like the artifact-level one: when the POM of a plugin registers and the stored file does not list its artifactId (a plugin thatmvn deploypublished first and that Gradle, sbt or Ivy then adds a second plugin to), Repsy appends a<plugin>entry (name, prefix, artifactId) for it, and for any other plugin it has registered for the group that the file lacks, after the entries that are there, rewrites the checksums that are stored next to it and deletes a storedmaven-metadata.xml.asc. It never changes or removes an entry, and leaves untouched a file it cannot parse and a file that lists versions and no plugins (the artifact-level file of the same path, see below). A plugin that sets its owngoalPrefixand is published bymvn deployinto a group whose file is stored already can end up listed twice, under the prefix derived from its artifactId (added when its POM arrived, before its jar, in Maven's order) and under its own (merged in by Maven afterwards); Maven finds the plugin by either. The path of that file has the shape of an artifact-level one (com/acme/tools/maven-metadata.xmlis both the artifacttoolsofcom.acmeand the groupcom.acme.tools), so the artifact-level answer comes first and the group-level one is given only when no artifact of that name is registered. Nothing generated is stored: it is not in the directory listing and is never signed (.ascis a404). The version-level<version>-SNAPSHOT/maven-metadata.xmlis not generated (RPS-1438): Maven and Gradle publish it themselves for a unique SNAPSHOT, and Ivy and sbt publish a non-unique one under its literal-SNAPSHOTfile names, which Maven, Gradle and Ivy resolve without it, so no client needs Repsy to write it.
Authenticating from CI
Prefer a deploy token for CI jobs, build servers and anything else that
sends credentials on every request (a Maven build that resolves hundreds of dependencies, a
docker pull of an image with many layers). A deploy token is checked with a single fast hash. A
user password is stored with BCrypt, which is slow on purpose, so an HTTP Basic request with a
username and password costs one BCrypt verification (tens of milliseconds of CPU) unless Repsy has
already seen that password succeed.
Repsy remembers successful Basic password checks for BASIC_AUTH_CACHE_TTL_SECONDS (5 minutes by
default), which removes that cost for clients that send the same credentials repeatedly. In a
local measurement of 300 authenticated API requests, this took a request from about 47 ms to under
2 ms, and the throughput of 8 concurrent clients from about 120 to about 1,700 requests per second.
The cache holds only a keyed digest, never a password. A changed password, a deleted user or a
changed role takes effect on the next request, and a wrong password or an unknown username is
never remembered, so it is checked and answered exactly as before. Set BASIC_AUTH_CACHE_ENABLED
to false to turn the cache off.
The cache does not make a failed login cheaper, so Repsy limits those instead. Every client, told
apart by its address (see Reverse Proxy), may make AUTH_THROTTLE_MAX_FAILURES
failed password checks (20 by default) per AUTH_THROTTLE_WINDOW_SECONDS (60 seconds by default).
The repository ports, the /api routes that take Basic credentials and the web UI login all count
into the same number. After that the client's next password check is answered with
429 Too Many Requests and a Retry-After header (the seconds left in the window) without spending
a BCrypt verification, until the window ends. That caps what a flood of wrong credentials costs at
about a second of CPU per client and minute. A client that keeps going, and uses up its failures in
AUTH_THROTTLE_BAN_AFTER_BLOCKED_WINDOWS windows in a row (3 by default), is then refused outright
for AUTH_THROTTLE_BAN_SECONDS (one hour by default), with the rest of the ban in Retry-After;
its remembered passwords stop working for that time too. The ban is kept in memory, so a restart
clears it, and with several instances each bans for itself. Set AUTH_THROTTLE_BAN_SECONDS to 0
to keep only the window, or AUTH_THROTTLE_ENABLED to false to turn the limit off.
- What counts: a failed password verification (a wrong password, or a username that does not
exist) and a credential sent as a
Bearervalue that Repsy does not recognise: a deploy token that was revoked, rotated or belongs to another repository, or a forged token (npm's_authToken, a NuGet API key, a Cargo or Ruby token). A validly signed token that has merely expired is recognised and does not count (it is answeredsessionExpired), so a longdocker pushwhose token runs out does not spend the budget of everyone behind the same address. All of them count the same and are refused the same way (401 unAuthorized), so the limit never reveals which usernames exist, and it is keyed on the client, never on the username. A request that succeeds, a valid deploy token, a valid bearer token, a recognised token that is refused (revoked or read-only for a write, or without the admin role for a management call) and a request that carries no username do not count, and a success does not reset the count: only the end of the window does. - A stale token in CI: a job that keeps sending a rotated or revoked token (an old
NPM_TOKEN, an outdated--api-key) spends the same budget as a wrong password.npm installsends many requests at once, so one stale token can use up the 20 failures within a second and block the address for the rest of the window, until the token is fixed. Valid tokens keep working while an address is blocked. Rotate the secret in the job as soon as you revoke it in Repsy. - Shared addresses: many users behind one address (a company NAT, shared CI egress) share one
count. A password Repsy remembers (see above), a valid deploy token and a valid bearer token cost no verification and keep
working for a client that is over the limit, so a CI job is not locked out by a neighbour that
sends wrong passwords. A client that keeps sending guesses after it was blocked (ten times the
limit) loses that as well until its window ends. Raise
AUTH_THROTTLE_MAX_FAILURESif a shared address regularly trips the limit. - Package managers: none of them is sent a
WWW-Authenticatechallenge with the 429, so none asks for credentials again in a loop. Maven and npm retry with a backoff; pip, twine, cargo, go, NuGet, gem and bundler show the HTTP error; Docker and Helm printtoomanyrequests. Fix the credentials in the job and wait for theRetry-Aftertime. - IPv6: the client is the address's
/64network, not the full address, since a single subscriber or site normally holds a whole/64. Two addresses of the same/64share one count.
A rate limit in your reverse proxy can be used in addition: it can also cap the request rate as a whole, which this limit does not do.
For detailed information on creating repositories, managing deploy tokens, and using different protocols (Golang, Cargo(Rust), Maven, npm, PyPI, Docker), see the documentation.
Troubleshooting
Common Issues
Port already in use:
# Check what's using port 8080 or 9090
lsof -i :8080
lsof -i :9090
Database connection failed:
# Check if PostgreSQL is running
docker ps | grep postgres
# Check PostgreSQL logs
docker logs repsy-postgres
Admin user not created:
# Check application logs
docker logs repsy
# Verify ADMIN_INITIAL_PASSWORD was set
docker exec repsy env | grep ADMIN
Can't login:
Verify
ADMIN_INITIAL_PASSWORDwas set before the first startupForgot admin password? Create an empty file named after the user in the password reset directory, from inside the container. No database access is needed, and it works for any user, not only for an admin. In the Docker image the directory is
/app/data/password-reset:# Docker docker exec repsy touch /app/data/password-reset/admin docker logs repsy 2>&1 | grep "New password" # Kubernetes kubectl exec deploy/repsy -- touch /app/data/password-reset/admin kubectl logs deploy/repsy | grep "New password"Within a few seconds (
PASSWORD_RESET_MARKER_POLL_INTERVAL) Repsy removes the file, generates a new random password for that user, revokes every session and refresh token of the account, and logs one line atWARN:Password of user admin has been reset by the marker file /app/data/password-reset/admin. New password: <password>Copy the password from the log, sign in and change it under Profile: the log may be shipped elsewhere. The file is removed before the password is changed, so a marker is applied only once. The file name must be a valid username (lower-case letters, digits,
_and-, 3 to 25 characters), and its content is never read. A marker for a user that does not exist is removed with a warning. Symlinks and directories are ignored. Outside the image, or with a differentPASSWORD_RESET_MARKER_DIR, use the directory you configured (<STORAGE_BASE_PATH>/password-resetby default).While Repsy is stopped (for example, with the embedded H2 database, whose file only one process can open), put the marker on the data volume and start Repsy: the directory is read once at startup. Create it with the Repsy image itself, not with a root shell such as
alpine: the image runs asappuser, so the marker (and the directory, if the volume was created by an image that predates this feature and does not have it yet) belongs to the user Repsy runs as. A directory created by root cannot be emptied by Repsy: it logs the errorCould not apply the password reset markerand leaves the file in place.docker stop repsy docker run --rm -v repsy-data:/app/data --entrypoint sh repo.repsy.io/repsy/os/repsy:latest \ -c 'mkdir -p /app/data/password-reset && touch /app/data/password-reset/admin' docker start repsy docker logs repsy 2>&1 | grep "New password"If the log shows more than one
New passwordline for the same user (a marker and an emptied hash, see below, applied at the same startup), use the last one.If the marker does nothing: check that
PASSWORD_RESET_MARKER_ENABLEDis notfalse, and that the directory is writable by the user Repsy runs as (appuserin the image; a bind-mounted/app/dataowned by root makes Repsy logCould not create the password reset marker directoryat startup; a marker Repsy cannot delete, for example on a read-only volume, is not applied and is logged atERRORasCould not apply the password reset marker, once per file, not on every poll). Anyone who can write to that directory can lock an account out (the account then has a password only the log holds), which is why it is a directory of its own, outside the protocol storage tree in the Docker image. SetPASSWORD_RESET_MARKER_ENABLED=falseif you do not want it.Alternative, without the marker directory (older images): reset the password by setting the hash to an empty string in the database (
hashisNOT NULL, soNULLis rejected):-- Connect to PostgreSQL docker exec -it repsy-postgres psql -U repsy -d repsy -- Reset the password of every admin UPDATE users SET hash = '' WHERE role = 'ADMIN'; -- Or of a single admin UPDATE users SET hash = '' WHERE role = 'ADMIN' AND username = 'admin'; -- Exit and restart the application \q docker restart repsy -- Check logs for the new random password docker logs repsy | grep "Admin password"On the next startup the application generates a new random password for every admin whose hash is empty and logs it, one line per admin.
On the embedded H2 database there is no
psql, and the database file is locked while the application runs, so stop it first and run the H2 shell once against the database file of yourDB_URL(/app/data/repsyfor the default URL in the environment variables) with theDB_USERNAMEandDB_PASSWORDthe application uses (repsy/repsy123by default):docker stop repsy docker run --rm -v repsy-data:/app/data --entrypoint java repo.repsy.io/repsy/os/repsy:latest \ -Dloader.main=org.h2.tools.Shell -cp /app/app.jar \ org.springframework.boot.loader.launch.PropertiesLauncher \ -url "jdbc:h2:file:/app/data/repsy;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE" \ -user repsy -password repsy123 \ -sql "UPDATE users SET hash = '' WHERE role = 'ADMIN'" docker start repsy # Check logs for the new random password docker logs repsy | grep "Admin password"Keep
MODE=PostgreSQL;DATABASE_TO_LOWER=TRUEin that URL even if yourDB_URLdiffers: the tables have lower-case names, and withoutDATABASE_TO_LOWER=TRUEthe statement fails withTable "USERS" not found. To reset a single admin, appendAND username = 'admin'to the statement.
Vulnerability scans failing with 401/500:
- Check that
TRIVY_SCANNER_API_KEY(repsy-backend) andSCANNER_API_KEY(repsy-scanner-trivy) are set to the exact same value — a mismatch causes the scanner to reject requests.
Docker scans failing:
- Remember that
DOCKER_INTERNAL_REGISTRY_BASE_URLis resolved from the scanner container's point of view, not the backend's — it must never belocalhost. Usehost.docker.internal(hybrid topology, backend on host) or the backend's service name (e.g.http://repsy:9090, full-compose topology) instead.
Logs
docker logs -f repsy
docker logs -f repsy-postgres
Reset Everything
# Stop and remove container
docker rm -f repsy
# Remove the named volume: it holds the H2 database and the artifact files (/app/data/storage)
docker volume rm repsy-data
# Start fresh (H2 example)
docker run -d \
--name repsy \
-p 8080:8080 \
-p 9090:9090 \
-e ADMIN_INITIAL_PASSWORD=YourSecurePassword123 \
-v repsy-data:/app/data \
repo.repsy.io/repsy/os/repsy:latest
Development
This section is for developers who want to contribute to or modify Repsy.
Prerequisites
- Java: JDK 25
- Spring Boot: 4.0.5
- PostgreSQL: 18
- Angular: 21
- Maven: 3.9.7 or higher
- Node.js 24.x (>=24.0.0 <25.0.0)
Project Structure
repsy/
├── repsy-backend/ # Spring Boot backend
│ ├── src/main/java/ # Java source code
│ ├── src/main/resources/ # Configuration files
│ └── src/test/ # Unit (*Test) and integration (*IT) tests
├── repsy-frontend/ # Angular frontend
│ ├── src/app/ # Angular components
│ └── src/assets/ # Static assets
├── libs/ # Shared libraries
│ ├── protocol-router/ # Protocol routing
│ ├── multiport/ # Multi-port handling
│ └── storage/ # Storage layer
└── repsy-protocols/ # Protocol implementations
Development Setup
# 1. Start PostgreSQL for development
docker run -d \
--name repsy-postgres \
-e POSTGRES_DB=repsy \
-e POSTGRES_USER=repsy \
-e POSTGRES_PASSWORD=repsy_123 \
-p 5432:5432 \
postgres:18
# 2. Build backend
mvn clean install -DskipTests
# 3. Run backend in development mode
cd repsy-backend
mvn spring-boot:run
# 4. In another terminal, run frontend
cd repsy-frontend
pnpm install
pnpm start
Access development environment:
- Frontend (Web UI): http://localhost:4200 (with hot reload)
(
mvn spring-boot:runactivates thedevprofile, which lets the dev server at:4200call the API cross-origin; see Cross-Origin Requests (CORS)) - Backend API: http://localhost:8080
- Repository Operations: http://localhost:9090
- To inspect the embedded H2 database directly, stop the app and open the database file with the H2 shell (see "Troubleshooting" below) — the TCP server (
H2_TCP_SERVER_ENABLED=true) only binds inside the container/host loopback, so it is not reachable from an external client and is not a supported inspection path
Building for Production
# Build Docker image (run from repo root)
docker build -f Dockerfile -t repsy:latest .
Running Tests
# Unit tests only (*Test.java) - no Docker needed
mvn test
# Unit + integration tests (*IT.java) - requires a running Docker daemon
mvn verify
Unit tests are named *Test and run in Surefire; integration tests are named *IT and run in Failsafe. Integration tests use Testcontainers to start a real PostgreSQL 18 container.
To run a single integration test class:
mvn verify -pl repsy-backend -am -Dit.test=ProfileControllerIT
Code Style
- Java: Follow Google Java Style Guide
- TypeScript/Angular: Follow Angular Style Guide
- Commits: Use conventional commits format
Database Migrations
Migrations are managed with Flyway in src/main/resources/db/migration/.
# File format: V{version}__{description}.sql
# Example: V0002__add_user_roles.sql
Contributing
- Fork the repository
- Create feature branch:
git checkout -b feature/amazing-feature - Commit changes:
git commit -m 'feat: add amazing feature' - Push to branch:
git push origin feature/amazing-feature - Open Pull Request
Contribution Guidelines:
- Write tests for new features
- Update documentation
- Follow existing code style
- Keep commits atomic and well-described
License
This project is licensed under the Apache License, Version 2.0 - see the LICENSE.txt file for details.
Support
- Documentation: docs.repsy.io
- Issues: GitHub Issues
Developed using Spring Boot and Angular
