Skip to content
Open
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
151 changes: 122 additions & 29 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,29 +1,122 @@
##########################################################################
# Plumber configuration file #
# Documentation: https://getplumber.io/docs/installation/docker-compose/ #
##########################################################################

# Required configuration
DOMAIN_NAME=""
JOBS_GITLAB_URL=""
ORGANIZATION=""
GITLAB_OAUTH2_CLIENT_ID=""
GITLAB_OAUTH2_CLIENT_SECRET=""

# Auto-generated secrets (populated by install script)
SECRET_KEY=""
JOBS_DB_PASSWORD=""
JOBS_REDIS_PASSWORD=""

# Deployment profile (set by install script)
# Options: letsencrypt or custom-certs, combined with internal-db or omit for external
COMPOSE_PROFILES="letsencrypt,internal-db"
CERT_RESOLVER="le"

# Optional: Override database defaults (only needed when NOT using internal-db profile)
# JOBS_DB_HOST="your-db-host"
# JOBS_DB_PORT="5432"
# JOBS_DB_USER="jobs"
# JOBS_DB_NAME="jobs"
# JOBS_DB_SSLMODE="disable"
# JOBS_DB_TIMEZONE="Europe/Paris"
# Plumber v2 configuration (compose.yml, root of this repo).
#
# Copy this file to .env, fill in every value under "Required", then
# `docker compose up -d`. See the root README.md for the full quick start
# and the first-run bootstrap step. v1's own .env.example lives unchanged in
# legacy-v1/ and is unrelated to this file.

# The release workflow rewrites this line in place (`sed -i
# "s/^PLATFORM_VERSION=.*/PLATFORM_VERSION=$V/" .env.example`) - keep it the
# very first line, keep the variable name exact. This is also the image tag
# for both docker.io/getplumber/platform-backend and platform-frontend.
PLATFORM_VERSION=v2.0.0

##############################################################################
# Required
##############################################################################

# --- Database ---------------------------------------------------------------
# Credentials for the bundled Postgres container (compose.yml's own
# "postgres" service) - the backend connects to it as PLUMBER_DB_HOST=postgres,
# hardcoded in compose.yml since it is a sibling service, not operator config.
PLUMBER_DB_USER=plumber
PLUMBER_DB_NAME=plumber
# Generate: openssl rand -hex 16
PLUMBER_DB_PASSWORD=

# --- Public origin / OIDC ----------------------------------------------------
# The externally reachable base URL of THIS install, no trailing slash (this
# is also what you put in front of a reverse proxy - see compose.yml's
# frontend service comment for why one is needed for the browser).
PLUMBER_BASE_URL=https://plumber.example.com

# The `aud` claim every CI OIDC token must carry to be accepted as a push.
# Must be BYTE-IDENTICAL to PLUMBER_BASE_URL above (this is what the
# `id_tokens:` block in a project's .gitlab-ci.yml is configured to request
# as its audience) - two different values here is a guaranteed
# "every push rejected" misconfiguration.
PLUMBER_OIDC_AUDIENCE=https://plumber.example.com

# Comma-separated list of GitLab instance base URLs this install trusts to
# issue CI OIDC tokens (e.g. https://gitlab.com or your self-hosted
# instance's URL). Leaving this empty means ingestion rejects every push -
# there is no useful default.
PLUMBER_OIDC_ALLOWED_ISSUERS=https://gitlab.com

# --- Secrets ------------------------------------------------------------------
# Seals every org token, OAuth client secret, and user OAuth token this
# install stores at rest (AES-256-GCM). MUST be exactly 64 hex characters
# (32 bytes) - a shorter value fails the backend at startup.
# Generate: openssl rand -hex 32
#
# There is no in-place rotation: swapping this value does not re-seal
# anything already stored under the old key, and every secret sealed under
# the old key becomes undecryptable (fails loudly, never silently). Back
# this value up somewhere safe - losing it means re-entering every org
# token, OAuth app secret, and having every user log in again.
PLUMBER_TOKEN_ENCRYPTION_KEY=

##############################################################################
# Optional - ports
##############################################################################

# Host-side ports compose publishes the two apps on. Change these if
# 8080/3000 are already taken on this host.
# BACKEND_PORT=8080
# FRONTEND_PORT=3000

##############################################################################
# Optional hardening knobs (all commented; uncomment to change the default)
##############################################################################

# Redis: PLUMBER_REDIS_ADDR (compose.yml, hardcoded to redis:6379 - the
# bundled Redis container has no auth/TLS since it never leaves the compose
# network) is the default. If you point at an external/managed Redis
# instead, edit compose.yml to set PLUMBER_REDIS_URL (a redis:// or
# rediss:// URL with credentials, mutually exclusive with PLUMBER_REDIS_ADDR)
# rather than adding both here.

# Postgres TLS - only meaningful if you edit compose.yml to point
# PLUMBER_DB_HOST at a real network hop instead of the bundled postgres
# service. Values: disable (default) | allow | prefer | require |
# verify-ca | verify-full.
# PLUMBER_DB_SSLMODE=disable
# PLUMBER_DB_SSLROOTCERT=/path/to/pg-ca.pem
# PLUMBER_DB_SSLCERT=/path/to/client-cert.pem
# PLUMBER_DB_SSLKEY=/path/to/client-key.pem

# Refuse a loopback/RFC1918/link-local base_url or SMTP relay address by
# mistake. Both default to false because on-prem/private GitLab and mail
# relays are the common, legitimate case for this product; the cloud
# metadata address (169.254.169.254 and friends) is always refused
# regardless of these flags.
# PLUMBER_BASE_URL_REJECT_PRIVATE=false
# PLUMBER_REPORT_SMTP_REJECT_PRIVATE_RELAY=false

# Behind a reverse proxy/ingress (recommended - see compose.yml's frontend
# note): trust the nearest untrusted X-Forwarded-For hop for the inbound
# rate limiter's per-IP key, instead of trusting only the direct TCP peer
# (which, behind a proxy, is always the proxy itself - every caller then
# shares ONE fleet-wide rate-limit budget). Only enable this once a real
# proxy is actually in front; enabling it without one lets any caller spoof
# its own rate-limit key via the header.
# PLUMBER_TRUST_FORWARDED_FOR=false
# PLUMBER_TRUSTED_PROXY_CIDRS=

# Air-gapped install: stop the daily "is a newer version available" dial-out
# entirely (default dials https://raw.githubusercontent.com/getplumber/platform/main/latest.json).
# PLUMBER_UPDATE_CHECK_URL=off

# Mark session cookies Secure - flip this on once this install is actually
# served over HTTPS (through your own reverse proxy).
# PLUMBER_COOKIE_SECURE=false

# How many days of full-granularity history (runs, project snapshots, sync
# attempts) this install keeps before the weekly retention sweep downsamples
# older data. Floor is 2 (48h); default 30.
# PLUMBER_RETENTION_DAYS=30

# Provider (GitLab) CA bundle: set this if your GitLab instance's TLS
# certificate is signed by an internal/private CA the backend does not
# already trust. PEM file path, mounted into the backend container.
# PLUMBER_PROVIDER_CA_BUNDLE=/path/to/gitlab-ca.pem
10 changes: 6 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,15 @@ jobs:
- name: Install chart-testing
uses: helm/chart-testing-action@afea100a513515fbd68b0e72a7bb0ae34cb62aec # v2.3.1
- name: Add dependency chart repositories
run: ./scripts/add_helm_repo.sh
# v1's own scripts moved to legacy-v1/ 2026-08-25 (root takeover by v2) - path updated,
# nothing else about this script changed.
run: ./legacy-v1/scripts/add_helm_repo.sh
- name: List changed charts
id: list-changed
env:
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
run: |
changed=$(ct list-changed --target-branch "$DEFAULT_BRANCH")
changed=$(ct list-changed --target-branch "$DEFAULT_BRANCH" --chart-dirs legacy-v1/charts)
charts=$(echo "$changed" | tr '\n' ' ' | xargs)
if [[ -n "$changed" ]]; then
echo "changed=true" >> $GITHUB_OUTPUT
Expand All @@ -51,15 +53,15 @@ jobs:
- name: Lint charts
env:
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
run: ct lint --target-branch "$DEFAULT_BRANCH"
run: ct lint --target-branch "$DEFAULT_BRANCH" --chart-dirs legacy-v1/charts
unit-tests:
name: Unit tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: d3adb5/helm-unittest-action@850bc76597579183998069830d5fa8c3ef0ea34a # 2.5.0
with:
charts: charts/plumber
charts: legacy-v1/charts/plumber
flags: --color -o test-results.xml --output-type JUnit
helm-version: v3.10.1
# helm-unittest v1.1.0 (2026-05-08) switched plugin.yaml to the
Expand Down
68 changes: 68 additions & 0 deletions .github/workflows/publish-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# Step 7 of the release-process spec (getplumber/monorepo,
# .github/workflows/release.yml): that workflow opens a release PR against
# THIS repo (branch release-$V) carrying an updated .env.example
# (PLATFORM_VERSION=$V), latest.json ({"latest":"$V"}), and
# releases/$V.md. Thomas reviews and merges that PR by hand - merging it is
# the release approval. This workflow reacts to that merge landing on main:
# it never opens or approves anything itself, it only turns an already
# merged version bump into a real git tag + GitHub Release.
#
# Deliberately narrow trigger (paths: latest.json) so this never fires on
# an unrelated push to main.
name: publish release

on:
push:
branches: [main]
paths: [latest.json]

concurrency: {group: publish-release, cancel-in-progress: false}

permissions:
contents: write

jobs:
publish:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: {fetch-depth: 0}

- name: resolve version
id: ver
run: |
set -euo pipefail
V=$(jq -r '.latest' latest.json)
if [ -z "$V" ] || [ "$V" = "null" ]; then
echo "::error::latest.json has no .latest field"
exit 1
fi
if ! [[ "$V" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "::error::latest.json's .latest ('$V') is not a vX.Y.Z version"
exit 1
fi
NOTES="releases/$V.md"
if [ ! -f "$NOTES" ]; then
echo "::error::$NOTES not found - the release PR that bumped latest.json to $V must also add it"
exit 1
fi
echo "version=$V" >> "$GITHUB_OUTPUT"
echo "notes=$NOTES" >> "$GITHUB_OUTPUT"

- name: tag + release (idempotent)
env:
GH_TOKEN: ${{ github.token }}
V: ${{ steps.ver.outputs.version }}
NOTES: ${{ steps.ver.outputs.notes }}
run: |
set -euo pipefail
if git rev-parse -q --verify "refs/tags/$V" >/dev/null; then
echo "tag $V already exists - nothing to do (re-run or a no-op latest.json change)"
exit 0
fi
git config user.name "plumber-release-bot"
git config user.email "release@getplumber.io"
git tag "$V"
git push origin "$V"
gh release create "$V" --title "$V" --notes-file "$NOTES"
6 changes: 5 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,13 @@ jobs:
with:
version: 3.10.1
- name: Add dependency chart repositories
run: ./scripts/add_helm_repo.sh
# v1's own scripts moved to legacy-v1/ 2026-08-25 (root takeover by v2) - path updated,
# nothing else about this script changed.
run: ./legacy-v1/scripts/add_helm_repo.sh
- name: Run chart-releaser
uses: helm/chart-releaser-action@cae68fefc6b5f367a0275617c9f83181ba54714f # v1.7.0
with:
charts_dir: legacy-v1/charts
env:
CR_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
CR_RELEASE_NAME_TEMPLATE: "v{{ .Version }}"
Expand Down
101 changes: 88 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,23 +1,98 @@
# Self-Managed Plumber
# Plumber Platform (self-managed)

[![Plumber Score](https://score.getplumber.io/github.com/getplumber/platform.svg)](https://score.getplumber.io/github.com/getplumber/platform)
[![CI](https://github.com/getplumber/platform/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/getplumber/platform/actions/workflows/ci.yml)
[![Release](https://github.com/getplumber/platform/actions/workflows/release.yml/badge.svg?branch=main)](https://github.com/getplumber/platform/actions/workflows/release.yml)

This project contains resources to setup a self-managed instance of [Plumber](https://getplumber.io/).
This repository contains everything needed to self-host [Plumber](https://getplumber.io/), the
CI/CD security & compliance control plane: [analysis stays in the open-source
CLI](https://github.com/getplumber/plumber), pushed here over native CI OIDC. This is the **v2**
line (compose-first, images published as `docker.io/getplumber/platform-backend` and
`docker.io/getplumber/platform-frontend`). Looking for the previous Helm/Compose/podman line? It
still lives, unchanged, in [`legacy-v1/`](legacy-v1/) - see `legacy-v1/README.md`.

## Installation
## Quick start (Docker Compose)

- 🐳 **Docker Compose** — [Documentation](https://getplumber.io/docs/installation/docker-compose/)
Requires Docker with the Compose plugin (`docker compose version`).

```bash
curl -fsSL https://raw.githubusercontent.com/getplumber/platform/main/install.sh | bash
```
1. **Configure.**

- ☸️ **Kubernetes with Helm** — [Documentation](https://getplumber.io/docs/installation/kubernetes/)
```bash
cp .env.example .env
```

## Contributions
Fill in every variable under "Required" in `.env` - the public URL, the GitLab instance(s)
allowed to push analysis results, and two secrets (`.env.example` gives you the exact
`openssl` command for each one). Leave the rest at their defaults for a first install; every
optional hardening knob is documented, commented out, in `.env.example` and `compose.yml`.

2. **Start the stack.**

```bash
docker compose up -d
```

This starts Postgres, Redis, the backend (runs its own database migrations on boot), and the
frontend. Give it a minute the first time - `docker compose ps` should show the backend and
frontend as `healthy` once migrations have applied and both apps are answering.

3. **Bootstrap the first GitLab instance.**

A fresh install has no configured GitLab connection yet, so there is nothing to log in
against. Run the bootstrap command once, inside the backend container, to configure it:

```bash
docker compose exec -e PLUMBER_BOOTSTRAP_CLIENT_SECRET=<your-gitlab-oauth-app-secret> \
backend plumber-bootstrap \
-base-url https://gitlab.example.com \
-client-id <your-gitlab-oauth-app-client-id> \
-scope instance
```

- `-base-url` and `-client-id` are required; create a GitLab OAuth application first (redirect
URI `<PLUMBER_BASE_URL>/auth/callback`) and pass its client id/secret here.
- `-e PLUMBER_BOOTSTRAP_CLIENT_SECRET=...` keeps the secret out of the container's own process
list (`-client-secret <value>` also works but is both shell-history- and `ps`-visible inside
the container - prefer the env form). The same applies to `-token`/
`PLUMBER_BOOTSTRAP_TOKEN` below.
- `-scope instance` connects every project on that GitLab instance; use `-scope group
-root-group <path>` instead to scope to one root group. Omit `-scope` entirely to leave the
connection scope unset for now (configure it later through the settings UI as an Admin).
- Optionally add `-e PLUMBER_BOOTSTRAP_TOKEN=<org-token>` to validate and store an org token in
the same run (sealed before storage, never printed) - with `-scope group` this also resolves
the root group immediately. Without a token, the connection is stored but cannot sync until
an Admin adds one later through the settings UI.
- The command prints a login hint on success (it never prints a secret or token). It is
self-limiting: it refuses to touch an already-configured instance, so it is always safe to
re-run against a fresh install and impossible to run twice by accident against a live one.

4. **Log in.** Visit `PLUMBER_BASE_URL` (via your reverse proxy - see the note in `compose.yml`'s
`frontend` service about why one is needed) and sign in with GitLab. The account behind the
very first login becomes this org's Admin automatically.

You are welcome to help us improve this repository!
## Upgrading

1. Bump `PLATFORM_VERSION` in `.env` to the new version.
2. `docker compose pull && docker compose up -d`.

Compose's default behavior (stop the old container, then start the replacement) already gives
single-version operation - nothing else to configure. Every migration this backend ships is
additive-only, so an in-place upgrade never requires a maintenance window for the schema itself;
sequential upgrades (one version at a time) are the supported, best-tested path. Rolling back is
just reverting `PLATFORM_VERSION` and re-running step 2 - never run a database downgrade against a
live install.

## Kubernetes / Helm

A v2 Helm chart is coming soon. Until then, `legacy-v1/charts/plumber` is the closest available
reference (it deploys v1, not v2 - the container images, environment variables, and data model are
different; do not point v1's chart at v2 images).

## Legacy v1

The previous install line (Helm chart, Docker Compose with bundled Traefik, podman, `install.sh`)
still lives, unchanged, in [`legacy-v1/`](legacy-v1/). See `legacy-v1/README.md` for what moved and
why.

## Contributions

🎮 Open an Issue or create Pull Requests from your fork
You are welcome to help us improve this repository! Open an Issue or create a Pull Request from
your fork.
Loading
Loading