EN | RU
hunshcn/gh-proxy fork in Go combined with new features for personal preferences and purposes of other contributors or/and like-minded people. It mounts on a path rather than the domain root, so it lives next to an existing site; it never announces itself — anything unauthenticated is a plain 404; and it is token-gated by default.
https://sub.example.com/ivanghproxy/TOKEN/https://github.com/cli/cli/releases/download/v2.62.0/gh_2.62.0_linux_amd64.tar.gz
└──────── your site ───┘└─ prefix ──┘└───┘ └──────────────── an ordinary GitHub URL ──────────────────────┘
token
- releases, branch and tag archives,
blob/raw, gists; git cloneandfetch(git smart HTTP) with no git configuration;pushworks too, but only with a write-capableGHP_UPSTREAM_TOKEN: the client's own credentials are stripped and never reach GitHub;- resumable and parallel downloads (
Rangepasses through); - server-side redirect following to GitHub's CDN backends;
- private repositories through your own PAT (
GHP_UPSTREAM_TOKEN); - restriction by owner/repository (allow and deny lists);
- an optional short URL form that drops the
https://github.compart.
git clone https://github.com/prettyleaf/gh-proxy && cd gh-proxy
cp .env.example .env
openssl rand -hex 24 # set GHP_PREFIX in .env
docker compose up -dlocation = /ivanghproxy {
return 404;
}
location /ivanghproxy/ {
proxy_pass http://127.0.0.1:8899;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_request_buffering off;
client_max_body_size 0;
proxy_read_timeout 1h;
proxy_send_timeout 1h;
proxy_redirect off;
add_header X-Robots-Tag "noindex, nofollow, noarchive" always;
access_log off;
}Check:
BASE='https://sub.example.com/ivanghproxy/YOUR_TOKEN'
curl -LO "$BASE/https://github.com/cli/cli/releases/download/v2.62.0/gh_2.62.0_linux_amd64.tar.gz"
git clone "$BASE/https://github.com/cli/browser"
curl -s -o /dev/null -w '%{http_code}\n' https://sub.example.com/ivanghproxy/ # 404See docs/nginx.md for more info.
The documents below are in Russian.
| docs/nginx.md | mounting on a path: working config, every directive explained, verification, troubleshooting, Caddy/Traefik/Cloudflare |
| docs/clients.md | git, curl, wget, aria2, insteadOf, CI, Dockerfile |
| docs/security.md | threat model: stealth 404, token leaks, SSRF boundaries, what never reaches GitHub |
Four equivalent ways:
# path segment — the primary one, and the only one git handles out of the box
curl "$BASE/https://raw.githubusercontent.com/cli/cli/trunk/README.md"
# headers — for curl/wget/CI
curl -H "Authorization: Bearer TOKEN" "https://sub.example.com/ivanghproxy/https://..."
curl -u "x:TOKEN" "https://sub.example.com/ivanghproxy/https://..."
curl -H "X-Proxy-Token: TOKEN" "https://sub.example.com/ivanghproxy/https://..."The token belongs in the path because the proxy answers 404 rather than 401: git makes its first request without credentials and waits for a 401 WWW-Authenticate to learn that it should authenticate. A stealth 404 never issues that challenge — and a token already written into the URL makes the exchange unnecessary.
GHP_DEFAULT_HOST lets the mount point stand in for GitHub itself, so the URL is
the GitHub one with the host chopped off:
GHP_DEFAULT_HOST=github.com,raw.githubusercontent.comhttps://github.com/prettyleaf/media/blob/main/logo.png
https://sub.example.com/ivanghproxy/TOKEN/prettyleaf/media/blob/main/logo.png # the same file
The hosts are tried in order: github.com shapes first (blob/raw, releases,
archives, tags, git), then raw.githubusercontent.com, which is what makes a
bare /owner/repo/ref/path work too:
curl -O "$BASE/prettyleaf/media/main/logo.png" # -> raw.githubusercontent.com
curl -LO "$BASE/cli/cli/releases/download/v1/gh.tar.gz" # -> github.com
git clone "$BASE/cli/browser"Only the hosts the proxy already talks to are accepted here; anything else is a startup error. A URL that does name a host keeps its meaning — it is never re-read as an owner name.
Empty by default, and worth keeping that way unless you want a mirror: with it
on, every /owner/repo/... path below the mount point is proxied, so it will
shadow real paths if you mount at GHP_PREFIX=/ on a domain that serves
anything else. The prefix and the token still gate access.
GHP_ALLOW_ANONYMOUS=1 disables authentication entirely. GHP_TOKEN must then
be empty; setting both is a startup error, as is setting neither — the proxy
refuses to boot rather than quietly turn into an open relay.
GHP_ALLOW_ANONYMOUS=1 GHP_PREFIX=/ivanghproxy/ ./bin/gh-proxyNo token segment is consumed from the path, so the URL is just the prefix followed by the GitHub URL:
BASE='https://sub.example.com/ivanghproxy'
curl -LO "$BASE/https://github.com/cli/cli/releases/download/v2.62.0/gh_2.62.0_linux_amd64.tar.gz"Private repositories need a credential the proxy presents to GitHub. Instead of
minting a PAT and pasting it into .env, point the proxy at a gh CLI that is
already logged in:
gh auth login # once, as the user that runs the proxy
GHP_UPSTREAM_TOKEN_SOURCE=gh # in .envStartup refuses if gh is missing or logged out, so a broken setup is a boot
error rather than an unexplained 404 on the first private download. The
credential is re-read every GHP_GH_REFRESH (default 5m), because gh rotates
OAuth tokens on its own schedule. GHP_UPSTREAM_TOKEN and this source together
are a startup error — pick one.
Two lookups, in order:
gh auth token --hostname github.com, whenever the binary is reachable.$GHP_GH_CONFIG_DIR/hosts.yml, when it is not.
The second one is what makes this work in Docker: the image is built on
scratch and has no gh to run, so mount gh's config directory read-only. The
container must run as a uid that can read hosts.yml (gh writes it 0600):
services:
gh-proxy:
user: "1000:1000" # the uid that owns ~/.config/gh
volumes:
- ${HOME}/.config/gh:/gh:ro
environment:
GHP_UPSTREAM_TOKEN_SOURCE: gh
GHP_GH_CONFIG_DIR: /ghIf gh stores the token in a system keyring rather than in hosts.yml, only the
gh binary can read it back — either run the proxy outside the container, or keep
using GHP_UPSTREAM_TOKEN.
Worth being explicit about the trade: this hands the proxy your whole GitHub
account, so anyone holding GHP_TOKEN reaches everything you can. A PAT scoped
to contents: read on just the repositories you serve is tighter. Either way,
bound it with GHP_ALLOW_LIST.
All of them are environment variables; the full annotated list is in .env.example.
| Variable | Default | |
|---|---|---|
GHP_TOKEN |
— | required unless GHP_ALLOW_ANONYMOUS=1. Secret, ≥16 characters, no /?# or spaces |
GHP_TOKEN_FILE |
— | read the token from a file (docker secrets) |
GHP_PREFIX |
/ |
mount point, must match the nginx location |
GHP_LISTEN |
0.0.0.0:8899 |
public listener |
GHP_ADMIN_LISTEN |
127.0.0.1:8900 |
/healthz, never published |
GHP_ALLOW_LIST |
empty | ivan, ivan/repo, */repo — empty means "any" |
GHP_DENY_LIST |
empty | same syntax, applied after the allow list |
GHP_DEFAULT_HOST |
empty | hosts tried when the URL names none, in order: github.com,raw.githubusercontent.com. See short form |
GHP_UPSTREAM_TOKEN |
— | GitHub PAT for private repos and rate limits |
GHP_UPSTREAM_TOKEN_SOURCE |
env |
gh takes that credential from an authenticated gh CLI instead. See private repos without a PAT |
GHP_GH_BIN / GHP_GH_HOST |
gh / github.com |
binary and GitHub host for the gh source |
GHP_GH_CONFIG_DIR |
gh's own default | where hosts.yml is looked up when the binary is unavailable |
GHP_GH_REFRESH |
5m |
how often the gh credential is re-read; 0 never |
GHP_SIZE_LIMIT |
0 |
over the limit → 302 to the real GitHub. 512MB, 2GB |
GHP_REDIRECT_HOSTS |
GitHub CDNs | where redirects may be followed (replaces the default) |
GHP_MAX_REDIRECTS |
5 |
|
GHP_CORS |
0 |
allow fetch() from a browser |
GHP_LOG_TARGETS |
0 |
log upstream URLs (with a token in the path, that logs secrets) |
GHP_ALLOW_ANONYMOUS |
0 |
disable authentication — open relay |
make test # go test ./...
make race # go test -race
make lint # go vet + gofmt
make build # bin/gh-proxy
make run # local run with a throwaway token
make token # openssl rand -hex 24Locally without Docker:
GHP_TOKEN=local-dev-token-0123456789 GHP_PREFIX=/ivanghproxy/ \
GHP_LISTEN=127.0.0.1:8899 ./bin/gh-proxyMIT