# tiny-proxy
A single-binary Go service that proxies and caches OCI/Docker images, Maven artifacts, GitHub and Gitea release downloads, and Debian/APT and Pacman package mirrors through named **virtual repositories**, each with its own upstream, upstream credentials, egress proxy (HTTP/SOCKS5), client auth mode, and cache TTLs. Cached objects live on the local filesystem or in S3 (or any S3-compatible store, e.g. MinIO).
## Run
```sh
go build -o tiny-proxy .
cp config.example.yaml config.yaml # edit to taste
./tiny-proxy -config config.yaml
```
## Usage
### Docker / OCI
The first path segment after `/v2/` names the virtual repo:
```sh
docker pull localhost:8080/dockerhub/library/nginx:1.27
```
Note: Docker treats plain HTTP registries as insecure — add the proxy host to `insecure-registries` in daemon.json, or put the proxy behind TLS.
### Maven
```
GET https://localhost:8080/maven/central/com/google/guava/guava/33.0.0-jre/guava-33.0.0-jre.jar
```
In `settings.xml`:
```xml
tiny-proxy
central
http://localhost:8080/maven/central
```
### GitHub releases
Paths are `/github////` where selector is a tag
or an alias — `latest` (latest stable release) or `snapshot` (most recent
prerelease):
| request | response |
|---|---|
| `/github/gh/golang/go/latest` | 302 → the resolved tag (or content in place when `redirect: false`) |
| `/github/gh/golang/go/v1.2.3.json` | release metadata: the GitHub API release object with each asset's `browser_download_url` rewritten to this proxy |
| `/github/gh/golang/go/latest.json` | same, resolved via the alias |
| `/github/gh/golang/go/v1.2.3/app.zip` | asset download, cached forever |
| `/github/gh/golang/go/latest/app.zip` | 302 → the asset under the resolved tag |
```sh
curl -s http://localhost:8080/github/gh/golang/go/latest.json | jq -r '.tag_name'
curl -LO http://localhost:8080/github/gh/golang/go/latest/go1.23.4.linux-amd64.tar.gz
```
- Metadata and alias resolutions are cached for `cache.metadata_ttl`; assets
are immutable and cached forever. Aliases share the tag's cache entries.
- `allow: ["myorg/*", "foo/bar"]` restricts which `owner/repo` pairs may be
proxied (`path.Match` globs). Omit it to allow everything.
- `redirect: false` serves alias URLs in place instead of 302-redirecting to
the resolved tag (cache keys are shared either way).
- Set `upstream.username`/`password` to a GitHub user + PAT for private repos
and to avoid the unauthenticated API rate limit (60 req/h/IP). For GitHub
Enterprise point `upstream.url` at your instance (API `/api/v3`).
- Tags containing `/` work in the `.json` and asset forms; `latest` and
`snapshot` are reserved names.
- Assets whose names end in `.json` work too: the proxy tries the tag
metadata first and serves the asset form when no such tag exists.
- `browser_download_url` values in `.json` metadata are root-relative paths
on this proxy (e.g. `/github/gh/o/r/v1.2.3/app.zip`), not absolute URLs.
- Assets are cached forever, matching the immutable-artifact assumption of
the other modules; if an upstream asset is re-uploaded under the same
name, delete the key under `data/github/…` to refresh.
### Gitea releases
Identical to the GitHub type — same URL grammar under `/gitea//...`
(aliases, `.json` metadata with rewritten asset URLs, immutable asset
cache). Point `upstream.url` at your Gitea instance; its API is served at
`/api/v1`. There is no default upstream. Set
`upstream.username`/`password` to a user + access token for private repos.
```sh
curl -s http://localhost:8080/gitea/gt/myorg/mytool/latest.json | jq -r '.tag_name'
curl -LO http://localhost:8080/gitea/gt/myorg/mytool/latest/mytool_linux-amd64
```
### Debian / APT
A pass-through cache for APT repositories (Debian, Ubuntu, or any mirror):
```
GET http://localhost:8080/debian/deb/dists/bookworm/Release
GET http://localhost:8080/debian/deb/pool/main/h/hello/hello_1.0-1_amd64.deb
```
In `sources.list`:
```
deb http://localhost:8080/debian/deb bookworm main contrib non-free-firmware
```
The UI page for a debian vrepo (`/ui/debian//`) generates these lines
for you: it probes the upstream mirror's `dists/` index and each suite's
`Release` file, then lists one ready-to-paste `deb` line per distro version
with that suite's real component list, plus the architectures each version
carries. Works for any APT archive — Debian's `bookworm`/`trixie`/`sid`,
Ubuntu's `noble`/`jammy`, `*-updates`, `*-backports` and friends. The probe
result is memoized per vrepo for `cache.metadata_ttl` (failures for a
minute), so browsing the page doesn't re-hit the mirror; `Release` files are
fetched in a bounded worker pool. When the mirror is unreachable the page
falls back to listing the dists already in the cache.
- Metadata under `dists/**` (Release, InRelease, Packages.*, Sources.*,
Contents-*, Translation-*) is cached for `cache.metadata_ttl`; anything
under `pool/` or `by-hash/` is content-addressed and cached forever. This
keeps `apt update` correct for by-hash-capable clients (apt 1.2+).
- Signatures need no special handling: `InRelease` and `Release.gpg` are
passed through byte-identical, so apt verifies them against the distro's
regular keyring. The proxy neither strips nor re-signs anything, and no
key needs to be installed on clients.
- All architectures a suite publishes are served (`binary-amd64`,
`binary-arm64`, …); pin one with `deb [arch=amd64] …`.
`deb-src` lines work the same way.
- Multiple `upstreams` may be configured as mirrors: a 404, 401, 403, 429 or
connection error falls through to the next one.
- `If-Modified-Since` revalidation is not forwarded; `metadata_ttl` is the
freshness knob. Keep it short (minutes) so `InRelease` and `Packages.*`
don't drift stale-inconsistent.
### Pacman
A pass-through cache for Arch Linux package mirrors:
```
GET http://localhost:8080/pacman/arch/core/os/x86_64/core.db
GET http://localhost:8080/pacman/arch/core/os/x86_64/linux-6.10.2-1-x86_64.pkg.tar.zst
```
In `pacman.conf`:
```
[core]
Server = http://localhost:8080/pacman/arch/$repo/os/$arch
```
- Repo databases (`.db`, `.files`, in any compression form, plus
their `.sig` and the `lastupdate` stamp) are cached for
`cache.metadata_ttl`; packages (`*.pkg.tar.*` and signatures) are
immutable and cached forever.
- Multiple `upstreams` may be configured as mirrors with
404/401/403/429/error fall-through.
- pacman has no native HTTP-auth support; run pacman vrepos in `public`
mode on a trusted network, or behind an auth-injecting local mirror.
## Configuration
See `config.example.yaml`. Per virtual repo:
| field | meaning |
|---|---|
| `type` | `docker`, `maven`, `github`, `gitea`, `debian` or `pacman` |
| `upstream.url` | registry / repository base URL (github: defaults to `https://github.com`; gitea: required, API at `/api/v1`) |
| `upstream.username` / `password` | optional upstream credentials. For Bearer-challenge registries (Docker Hub, GHCR) the proxy performs the token exchange automatically. |
| `upstream.proxy` | optional egress proxy: `http://`, `https://` or `socks5://[user:pass@]host:port` |
| `upstreams` | maven, debian, pacman only: ordered fallback list, replacing `upstream`. Each entry takes the same `url`/`username`/`password`/`proxy` fields plus (maven only) filters below. A 404, 401, 403, 429 or connection error falls through to the next upstream; any other status is returned as-is. |
| `upstreams[].groups` | maven only: wildcard group allowlist, e.g. `com.google.*` (`*` crosses dots; bare `com.google` also covers the subtree). Empty = all groups. |
| `upstreams[].artifacts` | maven only: wildcard artifact-name allowlist, e.g. `my-lib-*`. Empty = all artifacts. |
| `upstreams[].snapshots_only` | maven only: only `-SNAPSHOT` paths are requested from this upstream. |
| `upstream.cache` / `upstreams[].cache` | `true` (default) stores what this upstream serves; `false` proxies it through without writing to storage. Use it when the upstream already caches for you — typically another vrepo of this proxy. Reads are unaffected: anything already cached under the vrepo is still served from storage. |
| `upstream.retry.attempts` / `upstreams[].retry.attempts` | retries for this upstream, before the request falls through to the next one. Covers a `429 Too Many Requests`, a connection that fails before any response arrives, and a transfer that dies mid-body (resumed with a byte range). Default `2`; `0` disables retrying. |
| `upstream.retry.max_wait` | cap on a single wait between those retries, a `Retry-After` sent by the upstream included. Default `5s`. |
| `auth.mode` | `public` (no auth), `password` (any login + this password), `users` (map of login: password) |
| `cache.manifest_ttl` / `metadata_ttl` | how long tags / `maven-metadata.xml` / SNAPSHOTs / release metadata / APT and pacman indexes count as fresh. Blobs and released artifacts are immutable and cached forever. `0` = always revalidate. |
| `allow` | github, gitea only: glob allowlist of `owner/repo` pairs; empty = allow all |
| `redirect` | github, gitea only: `true` (default) 302s aliases to the resolved tag; `false` serves content under the alias URL |
### Retries, rate limits and interrupted transfers
Upstreams fail in three ways worth retrying, and `retry.attempts` /
`retry.max_wait` govern all three.
**Rate limits (429).** Upstreams throttle busy clients — `Received status code
429 from server: Too Many Requests` in a Maven or Gradle build. The proxy
absorbs it in two steps:
1. The upstream client retries the request `retry.attempts` times, waiting
the upstream's `Retry-After` (or an exponential backoff with jitter when
it sends none), capped at `retry.max_wait` per wait.
2. If it is still throttled, the request falls through to the next
`upstreams` entry, exactly like a 404.
A 429 is only returned to the client when no upstream could serve the path.
That answer wins over a 404 from another mirror — a throttled upstream may
well hold the artifact, so reporting it missing would make the build fail
for the wrong reason — and carries the upstream's `Retry-After` when it sent
one. Nothing about a 429 is cached.
Long throttling windows are not waited out: an upstream asking for minutes
is capped to `max_wait`, the attempts run out, and the next mirror is tried
instead of pinning the build's connection open. Retries are logged at warn
level (`upstream request failed, retrying`, with `reason=rate_limited`).
**Connection failures.** A request that never produces a response — connection
refused or reset, a TLS or DNS failure — is retried the same way
(`reason=transport_error`), then falls through to the next upstream. Only
bodyless `GET`/`HEAD` requests are retried, so a retry never has to replay a
request body. A cancelled request is not retried: the client has already gone.
**Interrupted transfers.** A connection that dies *after* the response headers
— `Error reading from server. Remote end closed connection` in apt — is past
the point any request-level retry can help, so the proxy resumes it instead:
it re-requests the remainder from the same upstream with
`Range: bytes=-` and `If-Range`, and carries on streaming to the client
where it left off. The resumed response is accepted only if it is a `206`
whose `Content-Range` starts exactly where the transfer stopped; a `200`
(the upstream ignoring the range, or the file having changed under us) is
refused rather than spliced into the middle of an object.
**Nothing broken is ever cached.** A transfer that ends short of the full
object — a read error, or a clean close after fewer bytes than the
`Content-Length` promised — is discarded rather than committed, so a
transient network hiccup cannot leave a truncated artifact in the cache.
Because released artifacts never expire, a truncated one used to stay
truncated forever. As a backstop, any stored object whose size disagrees with
its recorded metadata is discarded and refetched
(`corrupt cache entry, discarding`), which heals entries written before this
check existed — no manual cleanup needed.
### Chaining vrepos
A vrepo may list another vrepo of the same proxy as an upstream — an
aggregator in front of a shared cache. Without `cache: false` both layers
store the same bytes; with it the aggregator only routes:
```yaml
- name: central # caches Maven Central
type: maven
upstream: { url: https://repo1.maven.org/maven2 }
- name: deps # aggregator: routes, does not store
type: maven
upstreams:
- url: https://mvn.example.com
- url: http://localhost:8080/maven/proxy
cache: false
- url: http://localhost:8080/maven/central
cache: false
```
Point the chained upstream at this proxy's own base URL; it is an ordinary
HTTP request, so a vrepo with `auth.mode` other than `public` needs
`username`/`password` on the upstream entry. Keep the chain shallow — each
hop is a real request — and acyclic: a vrepo that reaches itself will
recurse until the client times out.
The UI's Upstream column shows each upstream's host and path, marking the
ones this vrepo does not cache with `(uncached)`.
### Metrics
`GET /metrics` serves Prometheus text exposition. Everything a request can be
blamed on carries a label: `vrepo` for what the client asked for, `vrepo` plus
`upstream` (the URL as configured) for what the proxy had to fetch.
| series | meaning |
|---|---|
| `tinyproxy_requests_total{type,vrepo}` | client requests per proxy type and repo. An empty `vrepo` is a request that reached no repo: unknown name, bad path, or auth refused |
| `tinyproxy_request_duration_seconds{type,vrepo}` | histogram of time spent serving a request in a vrepo — a cache hit and an upstream fetch both land here |
| `tinyproxy_cache_hits_total{type,vrepo}` / `_misses_total` / `_errors_total` | cache lookups served from storage, fetched from upstream, and failed |
| `tinyproxy_transfer_failures_total{type,vrepo}` | transfers that ended short of the full object and were discarded instead of cached |
| `tinyproxy_upstream_requests_total{vrepo,upstream}` | upstream attempts, counted per wire attempt — a retried 429, a token exchange and a resumed range request each count |
| `tinyproxy_upstream_responses_total{vrepo,upstream,status}` | upstream responses by status code |
| `tinyproxy_upstream_errors_total{vrepo,upstream}` | attempts that never produced a response (refused, reset, TLS or DNS failure) |
| `tinyproxy_upstream_received_bytes_total{vrepo,upstream}` / `_sent_bytes_total` | **body** bytes read from and written to upstreams |
| `tinyproxy_upstream_wire_received_bytes_total{vrepo,upstream}` / `_wire_sent_bytes_total` | **socket** bytes: headers, chunked framing, TLS records and handshakes included |
| `tinyproxy_upstream_connect_seconds{vrepo,upstream}` | histogram of dial time, DNS included |
| `tinyproxy_upstream_tls_handshake_seconds{vrepo,upstream}` | histogram of TLS handshake time |
| `tinyproxy_upstream_ttfb_seconds{vrepo,upstream}` | histogram of the wait between sending a request and the first response byte — the upstream's own think time |
| `tinyproxy_upstream_body_seconds{vrepo,upstream}` | histogram of body streaming time, from first byte to EOF or close |
| `tinyproxy_upstream_retries_total{reason}` | retried attempts, by `rate_limited` or `transport_error` |
| `tinyproxy_upstream_resumes_total{result}` | transfers that died mid-body and were re-requested with a byte range, by `ok` or `failed` |
| `tinyproxy_client_requests_total` | client requests across all endpoints, `/ui/` and `/metrics` included |
| `tinyproxy_client_received_bytes_total` / `_sent_bytes_total` | **body** bytes read from and written to clients |
| `tinyproxy_client_wire_received_bytes_total` / `_wire_sent_bytes_total` | **socket** bytes exchanged with clients, headers and framing included |
| `tinyproxy_cache_corrupt_entries_total` | stored objects found to disagree with their metadata; discarded and refetched |
| `tinyproxy_storage_items` / `tinyproxy_storage_bytes` | per-vrepo gauges, collected on scrape via one storage `List` per vrepo |
Plus Go runtime stats (`tinyproxy_uptime_seconds`, goroutines, memstats).
**Body vs wire bytes.** The body counters are payload — what the artifact
weighs. The wire counters are taken at the socket and are the real traffic:
request lines, headers, chunked framing and, upstream, the TLS records and
handshake. Upstream sockets belong to exactly one vrepo/upstream pair, so
those totals are labelled; client sockets are not, because a keep-alive
connection carries requests for several vrepos — so the client wire counters
are process-wide rather than split on a guess.
**Reading the timings.** The connection histograms only record when a
connection is actually opened, so a quiet `connect_seconds` beside a busy
`upstream_requests_total` means the pool is doing its job. `ttfb_seconds` is
the upstream thinking; `body_seconds` is the link. A rise in the first is the
upstream's problem, a rise in the second is usually yours.
Two ratios earn their keep. `tinyproxy_upstream_requests_total` over
`tinyproxy_client_requests_total` rises when an upstream starts throttling,
flapping or truncating transfers. And in a fallback list (maven, debian,
pacman) the `status="404"` share of `tinyproxy_upstream_responses_total` shows
how much of the walk each mirror is absorbing — a mirror that answers nothing
but 404 is costing a round trip on every miss and belongs later in the list.
`metrics.token` protects the endpoint — empty (the default) means open; when
set, clients must send `Authorization: Bearer ` or a
`?token=` query param. Use a 15-30s+ scrape interval: each scrape
lists every vrepo's storage prefix, and S3 `List` paginates the whole
prefix.
## How it works
- Read-only pull-through: `GET`/`HEAD` only; pushes get `405`.
- Cache miss → stream from upstream while tee-ing into storage (write-through, single-flight per key). Hit → served from storage.
- `storage.type: none` runs the proxy without any cache: every request goes upstream, nothing is stored, and the UI and storage gauges report empty. `upstream.cache: false` is the same idea scoped to one upstream.
- Docker image names are normalized for Docker Hub (`nginx` → `library/nginx`).
- Docker clients authenticate with plain Basic auth (supported natively by `docker login`); the Bearer token dance happens only between the proxy and the upstream.
- `/healthz` answers liveness probes; `/metrics` exposes Prometheus metrics.
- Every request gets a trace id, carried on every log line it causes and passed to the upstreams it fans out to. See [Logging](#logging).
### Logging
Each inbound request is given a trace id — adopted from the caller's
`X-Request-Id` or W3C `traceparent` when it sent one, minted otherwise — that
is echoed back in `X-Request-Id`, attached to every log record the request
produces, and forwarded to each upstream it calls. Grepping one id shows the
whole life of a request:
```
INFO upstream request trace=9f2a1c8e40b7d5e3 vrepo=debian-security url=https://deb.debian.org/debian-security/... status=200 dur_ms=41
WARN upstream transfer interrupted, resuming trace=9f2a1c8e40b7d5e3 key=debian/debian-security/pool/... offset=131072 attempts_left=1
INFO upstream request trace=9f2a1c8e40b7d5e3 vrepo=debian-security url=https://deb.debian.org/debian-security/... status=206 dur_ms=38
INFO request done trace=9f2a1c8e40b7d5e3 method=GET path=/debian/debian-security/pool/.../libblkid-dev_2.41.5-0+deb13u1_arm64.deb status=200 bytes=289456 dur_ms=402
```
Because chained tiny-proxy hops adopt each other's `X-Request-Id`, one id
follows a request across all of them.
| field | meaning |
|---|---|
| `log.level` | `debug`, `info`, `warn` or `error`. Default `info`, which already gives one line per finished client request and one per upstream attempt. `debug` adds a line when each request *arrives*, which only matters for requests that never finish. |
| `log.format` | `text` (default) or `json` |
| `log.requests` | inbound access log. Default `true`; `false` keeps the trace ids and metrics but drops the lines. |
| `log.upstream` | per-upstream request log. Default `true`. |
## Out of scope
Pushes/deploys, HTTPS termination (use a reverse proxy), cache eviction/GC, admin API.