Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 10 additions & 7 deletions docker-compose.dokploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,12 @@ services:
- dokploy-network
labels:
- traefik.enable=true
- traefik.http.services.trupu.loadbalancer.server.port=3000
- traefik.http.services.trupu-server.loadbalancer.server.port=3000

registry:
image: registry:2
ports:
- '127.0.0.1:6000:5000'
environment:
REGISTRY_STORAGE_DELETE_ENABLED: 'true'
volumes:
Expand All @@ -29,12 +31,13 @@ services:
labels:
- traefik.enable=true
# TODO: Change registry.example.com to your registry domain
- traefik.http.routers.registry.rule=Host(`registry.example.com`)
- traefik.http.routers.registry.entrypoints=websecure
- traefik.http.routers.registry.tls=true
- traefik.http.routers.registry.tls.certresolver=letsencrypt
- traefik.http.routers.registry.middlewares=trupu-auth
- traefik.http.services.registry.loadbalancer.server.port=5000
- traefik.http.routers.trupu-registry.rule=Host(`registry.example.com`)
- traefik.http.routers.trupu-registry.entrypoints=websecure
- traefik.http.routers.trupu-registry.tls=true
- traefik.http.routers.trupu-registry.tls.certresolver=letsencrypt
- traefik.http.routers.trupu-registry.middlewares=trupu-auth
- traefik.http.routers.trupu-registry.service=trupu-registry-svc
- traefik.http.services.trupu-registry-svc.loadbalancer.server.port=5000
# ForwardAuth middleware
- traefik.http.middlewares.trupu-auth.forwardauth.address=http://trupu:3000/auth
- traefik.http.middlewares.trupu-auth.forwardauth.authResponseHeaders=X-Trupu-Repository,X-Trupu-Workflow,X-Trupu-Ref,Www-Authenticate
Expand Down
115 changes: 115 additions & 0 deletions llms.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
# trupu

> Trusted Publishing for Docker registries using GitHub Actions OIDC.

trupu is an authentication server that enables passwordless Docker image pushes from GitHub Actions to a self-hosted Docker registry. It uses OpenID Connect (OIDC) tokens instead of long-lived credentials, implementing the OpenSSF Trusted Publishers standard.

## Architecture

GitHub Actions → Traefik (ForwardAuth) → trupu (/auth) → Docker Registry

- Traefik intercepts every request to the registry and forwards it to trupu's /auth endpoint
- trupu verifies the GitHub OIDC JWT against GitHub's JWKS
- On success, the request is proxied to the registry
- On failure, 401/403 is returned

## Project structure

This is a pnpm monorepo with two packages:

- packages/server — The Hono-based auth server (published as @trupu/server on npm and ghcr.io/nmerget/trupu on Docker)
- packages/docs — Astro Starlight documentation site (deployed to GitHub Pages)

## Server

The server is built with Hono on Node.js. It uses esbuild to bundle into a single minified file (~54kb).

### Key files

- src/index.ts — Entry point, wires provider + app + serve()
- src/app.ts — createApp(provider, config) factory, decoupled from serve() for testability
- src/config.ts — General server config (PORT, DEV_MODE, DEV_TOKEN)
- src/provider/types.ts — Provider interface and VerifiedIdentity type
- src/provider/github/config.ts — GitHub-specific config (ALLOWED_PUBLISHERS, ALLOWED_REFS, OIDC_AUDIENCE), parsePublishers()
- src/provider/github/index.ts — createGitHubProvider(), extractWorkflow(), matchRef()

### Provider interface

```typescript
interface VerifiedIdentity {
repository: string;
workflow: string;
ref: string;
}

interface Provider {
verifyToken(token: string): Promise<VerifiedIdentity>;
}
```

The provider pattern allows adding new CI providers (e.g. GitLab) by implementing this interface in a new provider/ subdirectory.

### Endpoints

- GET /auth — Traefik ForwardAuth endpoint. Accepts Bearer (OIDC token) and Basic (docker login) auth schemes. Returns 200 with identity headers, 401 with Www-Authenticate challenge, or 403 on invalid token.
- GET /healthz — Health check, returns { status: "ok" }

### Trusted publisher format

Publishers are configured as `owner/repo:workflow.yml` via the ALLOWED_PUBLISHERS environment variable. The workflow filename is extracted from the GitHub OIDC token's `job_workflow_ref` claim.

### Ref matching

ALLOWED_REFS supports exact matches and trailing wildcard patterns (e.g. `refs/tags/v*` matches `refs/tags/v1.0.0`).

## Environment variables

- PORT (default: 3000) — Server port
- DEV_MODE (default: false) — Skip OIDC verification, accept DEV_TOKEN
- DEV_TOKEN (default: trupu-dev-token) — Static token for dev mode
- ALLOWED_PUBLISHERS — Comma-separated owner/repo:workflow.yml entries
- ALLOWED_REFS — Comma-separated ref patterns (supports trailing *)
- OIDC_AUDIENCE (default: https://registry.example.com) — Expected aud claim

## Docker Compose files

- docker-compose.yml — Local development with bundled Traefik (file provider)
- docker-compose.dev.yml — Dev override enabling DEV_MODE and Traefik dashboard
- docker-compose.dokploy.yml — Production deployment on Dokploy (uses Docker labels, external dokploy-network)

## Testing

Tests use vitest with mock providers. 23 tests covering:
- Auth endpoint (Bearer, Basic, missing auth, dev mode)
- GitHub provider (parsePublishers, extractWorkflow, matchRef)

Run: `pnpm test`

## CI/CD

GitHub Actions workflow (.github/workflows/default.yml):
- ci — lint, format:check, test, build
- deploy — Astro docs to GitHub Pages
- release — Changesets version + npm publish with provenance
- publish-docker — Build and push Docker image to ghcr.io

Permissions follow least privilege: read-only default, elevated per job.

## Tech stack

- Runtime: Node.js 24
- Framework: Hono + @hono/node-server
- OIDC: jose (JWT verification via JWKS)
- Bundler: esbuild (single-file minified output)
- Tests: vitest
- Docs: Astro Starlight
- Package manager: pnpm
- Linting: eslint + typescript-eslint
- Formatting: prettier

## Links

- Documentation: https://nmerget.github.io/trupu/
- npm: https://www.npmjs.com/package/@trupu/server
- Docker: ghcr.io/nmerget/trupu
- GitHub: https://github.com/nmerget/trupu
4 changes: 4 additions & 0 deletions packages/docs/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ export default defineConfig({
items: [
{ label: 'Auth Flow', slug: 'reference/auth-flow' },
{ label: 'API Endpoints', slug: 'reference/api-endpoints' },
{
label: 'GitHub Actions Workflow',
slug: 'reference/github-actions-workflow',
},
],
},
],
Expand Down
99 changes: 43 additions & 56 deletions packages/docs/src/content/docs/deployment/dokploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,12 @@ services:
- dokploy-network
labels:
- traefik.enable=true
- traefik.http.services.trupu.loadbalancer.server.port=3000
- traefik.http.services.trupu-server.loadbalancer.server.port=3000

registry:
image: registry:2
ports:
- '127.0.0.1:6000:5000'
environment:
REGISTRY_STORAGE_DELETE_ENABLED: 'true'
volumes:
Expand All @@ -52,12 +54,14 @@ services:
labels:
- traefik.enable=true
# TODO: Change registry.example.com to your registry domain
- traefik.http.routers.registry.rule=Host(`registry.example.com`)
- traefik.http.routers.registry.entrypoints=websecure
- traefik.http.routers.registry.tls=true
- traefik.http.routers.registry.tls.certresolver=letsencrypt
- traefik.http.routers.registry.middlewares=trupu-auth
- traefik.http.services.registry.loadbalancer.server.port=5000
- traefik.http.routers.trupu-registry.rule=Host(`registry.example.com`)
- traefik.http.routers.trupu-registry.entrypoints=websecure
- traefik.http.routers.trupu-registry.tls=true
- traefik.http.routers.trupu-registry.tls.certresolver=letsencrypt
- traefik.http.routers.trupu-registry.middlewares=trupu-auth
- traefik.http.routers.trupu-registry.service=trupu-registry-svc
- traefik.http.services.trupu-registry-svc.loadbalancer.server.port=5000
# ForwardAuth middleware
- traefik.http.middlewares.trupu-auth.forwardauth.address=http://trupu:3000/auth
- traefik.http.middlewares.trupu-auth.forwardauth.authResponseHeaders=X-Trupu-Repository,X-Trupu-Workflow,X-Trupu-Ref,Www-Authenticate

Expand All @@ -79,6 +83,10 @@ The key values to change:
The registry has no public port of its own. All external traffic goes through Dokploy's Traefik, which routes requests based on the `Host()` domain. Traefik also handles TLS via Let's Encrypt automatically.
:::

:::caution
Dokploy auto-generates Traefik labels for each service. To avoid conflicts, all Traefik router/service/middleware names in this compose are prefixed with `trupu-`. Make sure to disable Dokploy's built-in domain configuration for this compose project — the labels handle routing directly.
:::

## Step 3: Deploy

Click **Deploy** in Dokploy. This will:
Expand All @@ -100,47 +108,7 @@ Dokploy's Traefik will automatically:

## Step 4: Configure GitHub Actions

In your repository, create `.github/workflows/publish.yml`:

```yaml
name: Publish Image

on:
push:
tags: ['v*']

permissions:
id-token: write
contents: read

jobs:
push:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Get OIDC token
id: oidc
run: |
# TODO: Change audience to match your OIDC_AUDIENCE
TOKEN=$(curl -s -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
"$ACTIONS_ID_TOKEN_REQUEST_URL&audience=https://registry.example.com" | jq -r .value)
echo "token=$TOKEN" >> "$GITHUB_OUTPUT"

- name: Login to registry
# TODO: Change to your registry domain
run: echo "${{ steps.oidc.outputs.token }}" | docker login registry.example.com -u oauth2 --password-stdin

- name: Build and push
run: |
# TODO: Change to your registry domain and image name
docker build -t registry.example.com/my-image:latest .
docker push registry.example.com/my-image:latest
```

:::caution
The `audience` parameter in the OIDC token request must exactly match your `OIDC_AUDIENCE` environment variable.
:::
See the [GitHub Actions Workflow](../../reference/github-actions-workflow/) reference for a complete example workflow. Set the `REGISTRY` env variable to your registry domain from Step 2.

## How it works on Dokploy

Expand Down Expand Up @@ -169,22 +137,40 @@ The `docker-compose.dokploy.yml` differs from the local development setup:

## Pulling images from the registry

Pushes from GitHub Actions go through Traefik and require OIDC authentication. But services running on the same Dokploy server can pull directly from the registry container, bypassing Traefik entirely — no authentication needed.
The registry's port 5000 is bound to `127.0.0.1:6000` on the host — accessible from the server itself but not from the internet. External traffic goes through Traefik with OIDC auth, while internal services pull directly via localhost without authentication.

Use the registry's internal hostname and port in your Dokploy compose services:
Other Dokploy compose services can pull images using `localhost:6000`:

```yaml
services:
app:
image: registry:5000/my-image:latest
image: localhost:6000/my-image:latest
networks:
- dokploy-network

networks:
dokploy-network:
external: true
```

Add `localhost:6000` to the Docker daemon's insecure registries on your Dokploy server (the registry runs plain HTTP internally).

Edit `/etc/docker/daemon.json`:

```json
{
"insecure-registries": ["localhost:6000"]
}
```

This works because the registry container is on the `dokploy-network` and exposes port 5000 internally without any auth middleware.
Then restart Docker:

```bash
sudo systemctl restart docker
```

:::caution
External pulls (from outside the Docker network) must go through Traefik and require authentication, just like pushes.
External pulls must go through Traefik at `registry.example.com` and require OIDC authentication.
:::

## Garbage collection
Expand All @@ -196,14 +182,15 @@ The Docker registry does not automatically clean up deleted image layers. Over t
1. In the Dokploy dashboard, open your trupu **Compose** service
2. Go to the **Schedule Jobs** tab
3. Create a new **Compose Job** targeting the `registry` service
4. Set the command to:
4. Set **Shell Type** to **Sh**
5. Set the command to:

```bash
registry garbage-collect /etc/docker/registry/config.yml --delete-untagged
```

5. Set the cron schedule — for example `0 3 * * *` to run daily at 3 AM
6. Save the job
6. Set the cron schedule — for example `0 3 * * *` to run daily at 3 AM
7. Save the job

The `--delete-untagged` flag also removes manifests that are no longer referenced by any tag.

Expand Down
37 changes: 1 addition & 36 deletions packages/docs/src/content/docs/getting-started/setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,42 +41,7 @@ This starts three services:

## GitHub Actions workflow

In your repository, create `.github/workflows/publish.yml`:

```yaml
name: Publish Image

on:
push:
tags: ['v*']

permissions:
id-token: write
contents: read

jobs:
push:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Get OIDC token
id: oidc
run: |
TOKEN=$(curl -s -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
"$ACTIONS_ID_TOKEN_REQUEST_URL&audience=https://registry.example.com" | jq -r .value)
echo "token=$TOKEN" >> "$GITHUB_OUTPUT"

- name: Login to registry
run: echo "${{ steps.oidc.outputs.token }}" | docker login registry.example.com:5000 -u oauth2 --password-stdin

- name: Build and push
run: |
docker build -t registry.example.com:5000/my-image:latest .
docker push registry.example.com:5000/my-image:latest
```

The `id-token: write` permission is required for GitHub to issue OIDC tokens. The `audience` must match your `OIDC_AUDIENCE` environment variable.
See the [GitHub Actions Workflow](../../reference/github-actions-workflow/) reference for a complete example workflow that pushes images to your trupu-protected registry.

## Local development

Expand Down
Loading
Loading