gitlab-badge-proxy
v0.1.4
Published
Server-side proxy that turns private GitLab pipeline/coverage status into public SVG badges, without ever exposing the GitLab instance's URL to clients.
Maintainers
Readme
gitlab-badge-proxy
A tiny Hono app that turns a private GitLab project's pipeline/coverage status into public SVG badges, without ever exposing the GitLab instance's hostname or an access token to the browser. Everything that touches the real GitLab URL happens server-side, inside the deployed function; the client only ever sees your own domain.
Why
GitLab's own badge images (/<namespace>/<project>/badges/<branch>/pipeline.svg) require a logged-in
session to view on a private project — there is no supported token query parameter for them (that was
disabled upstream for security reasons). This proxy instead calls the GitLab REST API (which does
accept a PRIVATE-TOKEN header) to read the latest pipeline status/coverage, then re-renders that as a
shields.io static badge, fetched and streamed server-side. The GitLab URL and
token never appear in any client-visible response, header, or redirect.
Routes
GET /pipeline.svg— latest pipeline status on the configured ref (passing/failing/running/unknown).GET /coverage.svg— coverage percentage of the latest pipeline on that ref, color-graded. Add?job=<job-name>to scope it to one job's coverage instead of the pipeline's aggregate, same as GitLab's own native badge query param.GET /release.svg— latest release's tag (version badge, blue for stable / orange for 0.x or pre-release-like tags), same coloring convention as shields.io's version badges.GET /tag.svg— most recently updated tag, same version-badge coloring asrelease.svg.GET /stars.svg,GET /forks.svg,GET /contributors.svg— repository counts (abbreviated for large numbers, e.g.12.3k).GET /issues.svg?state=opened|closed|all&labels=<comma,separated>— issue count for the given state (defaultall); green when the count is zero, yellow otherwise.GET /merge-requests.svg?state=opened|closed|merged|locked|all&labels=<comma,separated>— merge request count for the given state (defaultopened); shows "more than 10k" past GitLab's own count-header limit.GET /last-commit.svg?ref=<ref>&path=<file>— how long ago the most recent matching commit landed (refdefaults to the configuredGITLAB_REF).GET /license.svg— the project's detected license name, or "not specified".GET /languages.svg— the top language by percentage of code.GET /languages-count.svg— how many distinct languages GitLab detected.GET /labels.svg?name=<label-name>— renders that label's own color from GitLab.GET /go-mod.svg?filename=go.mod&ref=<ref>— the Go version declared ingo.mod(or another path given viafilename), as a version badge.GET /health— liveness check; does not read GitLab configuration.
On any failure (GitLab unreachable, bad token, pipeline has no coverage) the routes degrade to a grey "unknown" badge instead of erroring, so a broken/rotated token shows up as a stale badge, not a broken image or a leaked error message.
Configuration
All configuration is env-only — nothing GitLab-specific is hardcoded in the source, so this package
stays safe to publish and reuse. Copy .env.example and fill in your own values (as
Vercel project env vars in production, never committed):
| Variable | Required | Description |
|---|---|---|
| GITLAB_URL | yes | Base URL of your GitLab instance. |
| GITLAB_TOKEN | yes | Token with the read_api scope on the target project only. |
| GITLAB_PROJECT | yes | Project path (group/name) or numeric ID. |
| GITLAB_REF | no | Branch/ref to report on. Defaults to master. |
| CACHE_MAX_AGE_SECONDS | no | Edge cache lifetime for successful responses. Defaults to 60. |
| GITLAB_PROJECTS | no | Comma-separated whitelist for the GitLab-shaped routes below. Empty by default. |
GitLab-compatible badge URLs
GitLab's own badges live at <gitlab-url>/<namespace>/<project>/badges/<ref>/pipeline.svg (and
coverage.svg), plus <gitlab-url>/<namespace>/<project>/-/badges/release.svg for the latest
release (no ref — releases aren't tied to a branch). This proxy serves the same path shapes, so
existing badge links keep working when you swap the GitLab host for this proxy's — e.g.
https://badges.yourdomain.tld/ardenslab/pm2-dash/badges/main/coverage.svg or
https://badges.yourdomain.tld/ardenslab/pm2-dash/-/badges/release.svg.
For security, only project paths listed in GITLAB_PROJECTS are served this way; any other path
(or one that doesn't match /badges/<ref>/pipeline.svg, /badges/<ref>/coverage.svg, or
/-/badges/release.svg) returns a plain 404, the same as GitLab's own behavior for an unknown
project — the token is never used to probe or confirm the existence of a project you haven't
explicitly whitelisted. The ref segment is free-form (any branch of a whitelisted project), unlike
the fixed GITLAB_REF used by the flat /pipeline.svg / /coverage.svg routes above. The coverage
route also accepts ?job=<job-name>, same as the flat route.
Project-scoped badges beyond pipeline/coverage/release
The rest of the Routes above — stars, forks, contributors, issues, merge requests, last
commit, license, tag, languages, language count, labels, go.mod version — aren't part of GitLab's own
badge system, so there's no GitLab-native URL shape to mirror for them. This proxy has its own:
GET /<group/project>/badges/<name>.svg, e.g. https://badges.yourdomain.tld/ardenslab/pm2-dash/badges/stars.svg
or .../badges/issues.svg?state=opened&labels=bug. <name> is one of stars, forks,
contributors, issues, merge-requests, last-commit, license, tag, languages,
languages-count, labels, go-mod — same query params as the matching flat route (labels.svg
still needs ?name=). Gated by the same GITLAB_PROJECTS whitelist as the GitLab-shaped routes
above; an unlisted project 404s the same way.
Badge preview page
GET /debug/<group/project>?ref=<branch> renders an HTML page with every badge this proxy can
produce for that project: its own GitLab-shaped routes, the fixed-default-project flat routes, and
the shields.io badges that work through the gitlab_url emulation below — all as live <img> tags,
so you can eyeball everything at once instead of constructing and testing each URL by hand. GET
/debug alone redirects to the configured default project. Each embedded badge still goes through its
own route's whitelist check when the browser loads it, so an unlisted project just shows 404s in
place, same as using that badge URL directly.
GitLab API emulation (for shields.io and similar)
Some badge generators — notably shields.io's generic gitlab badges
(/gitlab/v/release/<project>, /gitlab/v/tag/<project>, etc.) — don't fetch a pre-rendered badge;
instead they take a gitlab_url query param and build their own GitLab REST API calls against it
(<gitlab_url>/api/v4/projects/<project>/releases, etc.). Pointing gitlab_url at your real GitLab
instance would require that instance to be publicly reachable. Point it at this proxy instead, and it
answers those specific calls on the real GitLab's behalf:
https://img.shields.io/gitlab/v/release/ardenslab%2Fpm2-dash?gitlab_url=https%3A%2F%2Fbadges.yourdomain.tld
https://img.shields.io/gitlab/v/tag/ardenslab%2Fpm2-dash?gitlab_url=https%3A%2F%2Fbadges.yourdomain.tld(Leave off the trailing slash on gitlab_url — with one, shields.io's own path gets appended with a
doubled /; the proxy redirects to collapse it, but it's one fewer hop to just omit it.)
Only a narrow, read-only slice of the real API is implemented — releases, repository/tags,
languages, issues_statistics, and pipelines/pipelines/:id/pipelines/:id/jobs — covering the
version/tag/pipeline/coverage/issue-count badge shapes. Same whitelist as the GitLab-shaped badge
routes above (GITLAB_PROJECTS): an unlisted project, or any subpath not on that list (file contents,
full issue/MR bodies, commit messages, contributor emails — anything with actual content rather than
metadata), 404s instead of being proxied. Every response body also has the real GITLAB_URL stripped
out first, since GitLab embeds it in web_url/_links on nearly every resource.
Installing as a library
npm install gitlab-badge-proxyThe package exports a ready-made Hono app plus the pieces used to build it:
import { app } from "gitlab-badge-proxy";
// app.request(...) / app.fetch(...) — mount it directly, or compose it into a bigger Hono app.import { createApp, loadConfig, type Config } from "gitlab-badge-proxy";
// Supply config explicitly instead of reading env vars:
const config: Config = {
gitlabUrl: "https://gitlab.example.net",
gitlabToken: process.env.MY_TOKEN!,
project: "group/project",
ref: "main",
cacheMaxAgeSeconds: 60,
};
const app = createApp(() => config);Hosting your own proxy
Whichever platform you pick, two things stay true everywhere: set the env vars from the
Configuration table above as that platform's secrets/environment feature (never
commit them), and attach a domain that doesn't hint at the real GitLab host (e.g.
badges.yourdomain.tld, not gitlab-proxy-for-<realcompany>.vercel.app) — that's the whole point of
this proxy.
On Vercel
Either install the package in your own project, or fork/clone this repo directly and deploy it as-is —
both end up running the same app.
As a dependency in your own project:
// api/index.ts
import { handle } from "hono/vercel";
import { app } from "gitlab-badge-proxy";
export const GET = handle(app);
export const runtime = "nodejs";// vercel.json
{ "rewrites": [{ "source": "/(.*)", "destination": "/api" }] }Set the env vars in the Vercel project settings (Production + Preview), then vercel deploy --prod.
Forking/cloning this repo instead: it already has api/index.ts and vercel.json set up the same
way — vercel link, set the same env vars, vercel deploy --prod, done.
On Cloudflare Workers
// src/index.ts
import { app } from "gitlab-badge-proxy";
export default app; // Hono apps are already a `fetch(request, env, ctx)` handler# wrangler.toml
name = "gitlab-badge-proxy"
main = "src/index.ts"
compatibility_date = "2024-01-01"Set GITLAB_URL / GITLAB_PROJECT / etc. with wrangler secret put <NAME> (or in the dashboard
under Settings > Variables) rather than plaintext [vars], since GITLAB_TOKEN is a credential. The
config module reads process.env, so wire Worker bindings into that shape before calling
createApp/loadConfig — e.g. createApp(() => loadConfig(env as unknown as Record<string, string>))
inside your fetch handler, since Workers pass env as a per-request argument rather than a global.
On plain Node.js (bare metal, a VM, or Docker)
// server.mjs
import { serve } from "@hono/node-server";
import { app } from "gitlab-badge-proxy";
serve({ fetch: app.fetch, port: Number(process.env.PORT) || 3000 });# Dockerfile
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install gitlab-badge-proxy @hono/node-server
COPY server.mjs ./
EXPOSE 3000
CMD ["node", "server.mjs"]Run with docker run -p 3000:3000 --env-file .env your-image-name, then put a reverse proxy
(Caddy/nginx/your platform's own ingress) with TLS in front of it on your domain.
Anywhere else
app is a standard Hono instance, and Hono ships adapters for most other JS runtimes — Deno, Bun, AWS
Lambda, Netlify Functions, Fastly Compute — covered in
Hono's own deployment docs. The app itself (src/app.ts)
has no platform-specific code; only the few lines that call serve()/handle() change per target.
Development
npm install
cp .env.example .env.local # fill in real values, never commit this file
npm run typecheck
npm test
npm run dev # plain Node server on :3000, serves the routes locally
npm run dev:vercel # optional: vercel dev, if you're testing the Vercel integration specificallyReleasing
CI/CD lives in .gitlab-ci.yml (this repo's origin is GitLab, not GitHub). Every
push/MR runs typecheck + test + build on Node 22 and 24. Publishing uses the shared
npm-publish CI/CD component — the same one
pm2-dash uses — instead of a bespoke publish job: it publishes whenever someone pushes to the
deploy-npm branch, relying on the existing prepublishOnly script to typecheck/test/build first and
on npm itself refusing to publish over a version that already exists (no separate version check).
One-time setup:
- Generate an npm Granular Access Token (https://www.npmjs.com/settings/~/tokens), scoped to this package, read+write, with "bypass 2FA" enabled (CI can't answer an OTP prompt).
- GitLab: Settings > CI/CD > Variables → add
NPM_TOKEN, flagged Masked + Protected. - GitLab: Settings > Repository > Protected branches → protect
deploy-npm(protected variables are only sent to protected branches/tags).
To cut a release:
npm version patch --no-git-tag-version # or minor / major — bumps package.json only
git commit -am "1.2.3"
git push origin HEAD:deploy-npmThe pipeline publishes automatically once that push lands; watch it under CI/CD > Pipelines.
License
MIT © ArdensLab
