This application provides a server to manage DEB,RPM and Docker repositories.
3.2K
This application provides a server to manage DEB, RPM, Helm and Docker repositories. It generates GPG keys and signs DEB metadata, produces APT and YUM repository metadata, and acts as a caching proxy for upstream container registries. It is a single static Go binary with no runtime dependencies, so it runs anywhere regardless of host distribution — the DEB and RPM repositories it serves are independent of the OS it runs on.
https://hub.docker.com/repository/docker/jlcox1970/package-server/general
docker pull jlcox1970/package-server:tagname
None at runtime. The server is pure Go and invokes no external processes — there is no os/exec anywhere in the codebase. You do not need dpkg-dev, apt-utils, createrepo, createrepo-c or gnupg installed, on either the host or in the container.
Everything those tools used to provide is now handled in-process:
| Function | Library |
|---|---|
| GPG key generation and metadata signing | github.com/ProtonMail/go-crypto/openpgp |
Reading .deb archives | github.com/blakesmith/ar |
| Compression for repo metadata | github.com/ulikunitz/xz |
Reading .rpm packages and generating repodata | github.com/cavaliergopher/rpm |
| Helm chart handling | helm.sh/helm/v3 |
| JWT / JWKS validation | github.com/golang-jwt/jwt/v4, github.com/MicahParks/keyfunc |
Building requires Go 1.24 or later. Because nothing is shelled out to, the binary runs on a FROM scratch image with no base OS.
Note that a scratch image ships no CA bundle. Upstream registry fetches work regardless because the mirror client skips certificate verification, but any HTTPS basic_api_backends you configure will fail with x509: certificate signed by unknown authority. Use a base image with ca-certificates, or mount a CA bundle, if you need those.
Clone the repository:
git clone https://gitlab.com/jlcox70/repository-server.git
Navigate to the project directory:
cd repository-server
Build the application:
go build -o repo-manager
Run the source code
go run .
(Optional) Install the binary globally:
sudo mv repo-manager /usr/local/bin/
| Variable | Purpose |
|---|---|
SERVER_CONFIG | Path to the config file. Defaults to ./server.yaml relative to the working directory. If the file is missing the server still starts, but with a zero-value config — which means the proxy is disabled. |
REGISTRY_ENABLE_PROXY | Enables the upstream proxy. Overrides the config file. |
REGISTRY_DISABLE_PROXY | Disables the upstream proxy. Evaluated first and wins outright. |
REGISTRY_UPSTREAM_MIRROR | Next upstream to request images via, for chaining proxies. When set it is authoritative: fallbackHosts is ignored entirely. If the upstream is unavailable the request goes direct to the origin registry. |
REGISTRY_UPSTREAM_VIA | Connect host to dial while keeping REGISTRY_UPSTREAM_MIRROR as the logical host (SNI / Host header). |
REGISTRY_DEBUG | Verbose request and proxy logging. Read before the config file loads, so it works even when the config is missing. |
REGISTRY_CACHE_PURGE_TTL | Evict cached content untouched for this long, e.g. 720h. Auto-purge is disabled until this is set and the cache will grow without bound. |
REGISTRY_CACHE_PURGE_INTERVAL | How often the sweep runs. Default 1h. |
REGISTRY_CACHE_MAX_BYTES | Optional total cache-size cap, e.g. 50GB or raw bytes. Evicts oldest-accessed first regardless of TTL. Better suited to a fixed-size volume than a TTL alone. |
REGISTRY_PURGE_TOKEN | Bearer token for the manual /purge endpoint. The endpoint returns 403 unless this is set. |
Truthy values for the boolean variables are 1, true, yes, on (case-insensitive). Any other non-empty value is treated as false, so REGISTRY_ENABLE_PROXY=enabled silently disables the proxy and the config file is never consulted. Leaving a variable unset is safer than setting it wrongly.
/healthz, /readyz and /livez all return 200 unauthenticated and bypass the registry auth path. Suitable for Kubernetes probes.
First boot generates an RSA GPG key in-process, which can take a while on a CPU-limited container. Use a startupProbe with a generous failureThreshold — if key generation fails the process exits before it starts listening.
--origin: Specifies the APT::FTPArchive::Release::Origin value for the APT repository.
InternalPackages--origin="MyRepo"--components: Specifies the APT::FTPArchive::Release::Components value.
main--components="main contrib"--label: Sets the APT::FTPArchive::Release::Label value.
Custom Packages--label="MyRepo Packages"--architectures: Defines the APT::FTPArchive::Release::Architectures value.
i386 amd64 all source--architectures="amd64 arm64"--suite: Sets the APT::FTPArchive::Release::Suite value.
stable--suite="testing"--email: Specifies the email address for GPG key generation.
[email protected]--email="[email protected]"--key_name: Specifies the user name for the GPG key.
Package Server--key_name="MyRepo Key"Run Server with defaults:
./repo-manager
Generate APT repository configuration with custom values:
./repo-manager --origin="MyRepo" --components="main contrib" --label="MyRepo Packages" --architectures="amd64 arm64" --suite="stable" --email="[email protected]" --key_name="MyRepo GPG Key"
Upload DEB to repository
curl -F "file=@k8s-diagnostics_1.0.1_amd64.deb" http://{package server}/upload
curl -F "[email protected]_64.rpm" http://{package server}/upload
docker push {package server}/{username}/{container}
helm repo add my-repo http://{package server}/charts
helm push {chart file} my-repo
Release and InRelease files are generated and signed in Go.repodata including primary.xml is generated in Go.Both are produced entirely in-process; no apt-ftparchive or createrepo invocation is involved.
All files are kept in /var/www and should be backed up.
Cached upstream content lives under __mirror__/<host>/<repo>/ inside that tree. It is an internal implementation detail and is excluded from /v2/_catalog, so registry browsers such as the VS Code Docker extension show only your own repositories.
The tool generates a GPG key for signing packages. The key is created with default settings or customized based on command-line arguments.
auth:
mode: basic
basic_htpasswd_file: ./.htpasswd
basic_api_backends:
- https://{rest server auth}
basic_api_timeout: 3s
basic_api_cache_ttl: 5m
# (Optional) Explicitly list path policies, though not required for 'none'
policies:
- path: /v2/controlled/
required: push
- path: /v2/public/
required: none
registry:
enableProxy: true
fallbackHosts:
- ghcr.io
- docker.io
- quay.io
- nvcr.io
- registry.k8s.io
debug: false
Note that keys under auth: are snake_case while keys under registry: are camelCase.
Unknown keys are ignored silently, so a misspelling produces no error — check the startup log to confirm what actually loaded.
Three modes exist:
| mode | Description |
|---|---|
none | no auth is performed |
basic | standard basic auth against basic_htpasswd_file, falling back to the REST backends |
oidc-jwks | validates bearer JWTs against the JWKS endpoint configured under auth.oidc |
*** the htpasswd file is used first and only moves to REST auth if the user is not listed in the file
The rest server needs to take an auth request body with {"username": "[name]","password":"[password]"} and return a JWT.
The JWT is then used by your container tools for auth; the api backend must also validate the JWT.
Only a 200 response will allow the user to access the container repository.
basic_api_timeout bounds each backend call (default 3s) and basic_api_cache_ttl caches accepted credentials (default 5m).
You can supply a list of api backends to use and they will be checked in order until a 200 is returned, or the list is exhausted at which time a 401 is sent to the client to deny access.
Policies are matched by path prefix, first match wins, so list more specific paths before general ones. If no policy matches, the path is public.
| required | Description |
|---|---|
none | no auth is performed on the path |
pull | auth is performed on all access, pull and push alike |
push | auth is performed on push only; pulls are anonymous |
Any unrecognised value is treated conservatively and requires auth.
For a pull-through cache that should serve anonymously but require credentials to publish, use required: push.
enableProxy allows you to proxy via the repository to upstream container registries. Also settable with REGISTRY_ENABLE_PROXY.
When enabled, a request resolves an upstream in this order:
REGISTRY_UPSTREAM_MIRROR, if set. Authoritative — nothing else is consulted. This is how proxy chaining works: an edge instance forwards to a central instance, which does the real upstream fetching.ns query parameter, if the client sent one. containerd and CRI-O send ?ns=quay.io when configured as a mirror via hosts.toml, which identifies the intended registry exactly.fallbackHosts, tried in order until one returns the content.Docker Engine and Podman do not send ns, so their requests always walk the fallback list. Order it with your most-used registries first: a miss costs a full round trip per host before moving on.
fallbackHosts behaviour:
| Setting | Result |
|---|---|
| key absent | defaults to registry.k8s.io, ghcr.io, docker.io |
fallbackHosts: [] | no fallbacks; only ns or REGISTRY_UPSTREAM_MIRROR resolve |
| explicit list | exactly that list, in that order |
| proxy disabled | no fallbacks, regardless of the above |
Whether a cached manifest is served directly or revalidated upstream depends on the shape of the reference:
| Reference | Treated as | Behaviour |
|---|---|---|
sha256:... | immutable | always served from cache when present |
starts with a digit — 1.2.9, 24.04 | immutable | served from cache when present |
v + digit — v1.2.9, v2 | immutable | served from cache when present |
starts with a letter — latest, stable, full-cuda, main | mutable | revalidated upstream on every request |
| anything else | mutable | revalidated upstream on every request |
Case is folded, so V1.2.9 is immutable and Latest is mutable.
If upstream is unreachable or rate-limited, revalidation degrades gracefully and the cached manifest is served.
Note that floating version tags such as ubuntu:24.04 or node:20 begin with a digit and are therefore treated as immutable, even though upstream rebuilds them. Pull by digest, or purge the cache, if you need those refreshed.
Layer transfers are designed to make forward progress under unreliable networks:
Range request on the next attempt rather than restarting from zero. Large layers converge over several attempts instead of looping.Interrupted transfers leave partial-<digest> files in the blob directory. These are removed by the cache purger, which requires REGISTRY_CACHE_PURGE_TTL or REGISTRY_CACHE_MAX_BYTES to be set.
All requests to the server are logged, including POST requests with filenames and other details. Set REGISTRY_DEBUG=true for request headers and upstream proxy detail.
Useful log lines when diagnosing the proxy:
| Line | Meaning |
|---|---|
config: reading "..." | config file found and parsed |
config: cannot stat "..." | config file missing — the proxy will be disabled |
config: registry.mirror_enabled= | whether the proxy is actually on |
mirror: fallback hosts set to [...] | the effective fallback list |
manifests: local miss for X, fetching upstream | the proxy path was entered |
mirror: try manifest connect= / mirror: fetch manifest OK | an upstream fetch happened |
mirror: resuming sha256:... from byte N | a partial transfer was continued |
If a pull returns 404 with no mirror: lines and a near-zero duration, the proxy is disabled — check mirror_enabled above.
This project is licensed under the MIT License. See the LICENSE file for more information.
Content type
Image
Digest
sha256:ed68c1e17…
Size
6.7 MB
Last updated
about 2 months ago
docker pull jlcox1970/package-server:2.0.5