Bridge UI
Bridge is a backend-for-frontend HTTP server embedded in the operator. It exposes CRUD over the
GitLabCore custom resource (apps.gitlab.com/v2alpha1) and serves a single-page application (SPA)
to configure GitLab instances. Bridge is disabled by default.
This page describes how to enable Bridge, create a service account for a caller, mint a token, and reach the UI. For what Bridge does, see Bridge API. For the internal architecture and how to work on the code, see internal/bridge/AGENTS.md.
Authentication model
Bridge uses caller-identity delegation, like the old Kubernetes Dashboard. Bridge does not
use the operator service account for API calls. Instead, every request to /api must carry a bearer
token:
This applies to Bridge running in the cluster. The kubectl bridge plugin runs Bridge on
your machine and authenticates with your kubeconfig instead, so it needs no bearer token. For more
information, see Run Bridge as a kubectl plugin.
Authorization: Bearer <token>The Kubernetes API server authenticates and authorizes each call with that token. A caller with no
token receives a 401 response, and an action the caller cannot perform a 403 response. Each
caller therefore needs their own RBAC on gitlabcores.apps.gitlab.com, and on
siphons.apps.gitlab.com for the Siphon add-on. For why Bridge works this way, see
ADR 34.
Build Bridge
Bridge is gated behind the bridge Go build tag, so it is absent from public operator
images. Only a build produced with -tags bridge contains the Bridge server and its SPA. CI
publishes these as separate images with a -bridge tag suffix: <branch-ref-slug>-bridge on branch
and merge request pipelines, and latest-bridge on the default branch. Release tags never produce a
Bridge image.
Build a Bridge image locally with the dedicated task or Dockerfile:
CONTAINER_CLI=docker task docker-build-bridge # tags <image>:<TAG>-bridge
# or: docker build -f Dockerfile.bridge -t <image>:<tag>-bridge .For a local operator process, build with the tag:
go build -tags bridge -o bin/manager ./cmd/managerEnable Bridge
Enabling requires a Bridge build (above); ENABLE_BRIDGE has no effect in a public image because
Bridge is not compiled in. Set ENABLE_BRIDGE=true, which the operator reads in
controllers/settings/settings.go. The bind address
defaults to :8090. To change it, set BRIDGE_BIND_ADDRESS.
To enable Bridge in-cluster, deploy a
-bridgeimage and set the chart valuebridge.enabled=true. The manager Deployment then injectsENABLE_BRIDGEandBRIDGE_BIND_ADDRESSand opens the container port.export HELM_CHARTS=$(pwd)/charts CHART_VERSION=$(head -n1 CHART_VERSIONS) ARGS='--set bridge.enabled=true --set image.tag=latest-bridge' task deploy_operatorTo enable Bridge locally, set the variable when you run the tagged binary:
export HELM_CHARTS=$(pwd)/charts CHART_VERSION=$(head -n1 CHART_VERSIONS) ENABLE_BRIDGE=true go run -tags bridge ./cmd/managerA local run has no chart keys mounted, so a
GitLabCoreat a version thatHELM_CHARTSdoes not carry fails to pull. To pull it anyway, turn verification off withDYNAMIC_CHART_VERIFY=false, or pointDYNAMIC_CHART_KEYRINGat an exported key. For more information, see Chart provenance verification.
Confirm the server started:
kubectl -n gitlab-system logs deploy/gitlab-controller-manager | grep bridge
# -> "starting bridge server","addr":":8090"The chart exposes only a container port for Bridge, with no Service or Ingress. Reach it
with kubectl port-forward. Do not expose it publicly while it is a proof of concept.
Install the v2alpha1 custom resources
The apps.gitlab.com/v2alpha1 resources that
ADR 26 designs are not part of the Helm chart.
The chart is what produces the release manifests and the OLM bundle, so leaving them out keeps them
out of everything a user installs.
Install them in a development cluster:
task install_v2alpha1_crdsThe task acts on the current kubectl context. Check it first, because a development cluster can
hold a real GitLab instance. It reads $NAMESPACE and $NAME_OVERRIDE to find the webhook Service,
so use the same values you deployed the Operator with.
The task adds three definitions, each with v2alpha1 as its only version:
| Definition | Kind |
|---|---|
gitlabcores.apps.gitlab.com | GitLabCore |
orbits.apps.gitlab.com | Orbit |
siphons.apps.gitlab.com | Siphon |
GitLabCore is a definition of its own, not a second version of GitLab. A definition carries one
kind across all of its versions, so a differently named kind needs a definition of its own. The
existing gitlabs.apps.gitlab.com definition is untouched, keeps v1beta1 as its only served and
stored version, and needs no conversion webhook.
Nothing converts a GitLab into a GitLabCore. Kubernetes converts only between versions of one
definition, so an instance created through GitLab stays there. To work with the new resource,
start from the
GitLabCore sample.
Bridge reads and writes GitLabCore and Siphon, so install the definitions before you use
it. Without them, every API call returns an error from the Kubernetes API server.
GitLabCore and Siphon are reconciled only in a build with the bridge tag and
ENABLE_BRIDGE=true. Creating an Orbit stores the object and nothing else happens. For more
information, see the GitLabCore reconciler.
Create a service account and grant access
Create a service account and bind it to a role with the verbs the caller needs on GitLabCore and
Siphon resources. This example grants full CRUD. For a read-only caller, drop create, update,
patch, and delete.
kubectl -n gitlab-system create serviceaccount bridge-user
kubectl create clusterrole gitlab-editor \
--verb=get,list,watch,create,update,patch,delete \
--resource=gitlabcores.apps.gitlab.com,siphons.apps.gitlab.com
kubectl create clusterrolebinding bridge-user \
--clusterrole=gitlab-editor \
--serviceaccount=gitlab-system:bridge-userTo limit the caller to a single namespace, use a Role and RoleBinding instead of the
cluster-scoped variants.
Get a token
Mint a short-lived token for the service account with the TokenRequest API (Kubernetes 1.24 and later):
TOKEN=$(kubectl -n gitlab-system create token bridge-user --duration=1h)A kubeconfig that authenticates with a client certificate or an exec or OIDC plugin cannot be
reduced to a bearer token. In that case, use kubectl create token <service_account> or your OIDC
ID token instead.
Access Bridge
Forward the port, then use the token:
kubectl -n gitlab-system port-forward deploy/gitlab-controller-manager 8090:8090Use
curlwith the token:curl -H "Authorization: Bearer $TOKEN" localhost:8090/api/v1/gitlabsIn the SPA, open http://localhost:8090/, paste the token into the header token field, and select Save token. The SPA attaches the token to every API request and stores it in the browser
localStorage.For the API documentation, open http://localhost:8090/docs, select Authorize, and paste the token to try requests from the documentation UI.
The SPA stores the token in localStorage, which any script on the page can read. This is
acceptable for the current proof of concept with short-lived tokens. Do not treat it as a
production credential store.
Configure a GitLab instance
For what the form guarantees, see GitLab instance form.
The chart version field is free-form with no default: a version the Operator does not bundle is
pulled from its chart repository (https://charts.gitlab.io/ by default) when it starts
reconciling, rather than failing outright, so the SPA has no fixed list of versions to prefill from.
The form in the SPA writes one GitLabCore resource. A sidebar lists its sections, and selecting
one shows its fields:
- Basics: what the instance is, and which chart deploys it.
- Dependencies: the data stores the instance connects to.
- Networking: how the instance is reached. Manual mode turns the Gateway API off (the Operator does not install the chart’s bundled Envoy Gateway), leaving networking to the overrides.
- Add-ons: the GitLab subcomponents the instance can turn on, each behind a checkbox.
Secret Manager writes
spec.openbao. Siphon writes a resource of its own, described in Configure the Siphon add-on. The rest are placeholders. - Overrides: the chart values, for everything the sections above do not cover.
Moving between sections checks nothing, so you can look at another one with a half-filled section behind you. A section with unsaved changes carries a dot, and one that needs attention a red mark. Create and Save check every section and open the first one that does not hold up.
Each field maps to the specification:
| Field | Resource field | Description |
|---|---|---|
| Name | metadata | Name of the resource, fixed after creation. The form creates in the gitlab-system namespace and offers no choice of it; a resource in another namespace is still edited where it is. |
| Hostname | spec.hostname | Fully qualified domain name the instance is reached at, such as gitlab.example.com. The reconciler derives the chart host values from it. |
| Edition | spec.edition | ee for Enterprise Edition, which runs the Free feature set until a license activates more, or ce for Community Edition. Defaults to ee. |
| License | spec.license.secretRef | Name and key of the Secret that holds the license. The license key itself never reaches Bridge or the resource. Leave both empty to run without a license. The form shows this group for Enterprise Edition only, and sends no license for Community Edition. |
| PostgreSQL | spec.postgresql | Hostname of the database server, and the Secret that holds the password of the database user. For the versions and extensions GitLab requires, see the PostgreSQL requirements. |
| Valkey | spec.redis | Hostname of the Valkey server, and the Secret that holds its password. Redis works in its place, and the resource and the chart values both still call the field redis. For the versions GitLab requires, see the Redis requirements. |
| Object storage | spec.objectStorage | Name and key of the Secret that holds the object storage connection. Setting it turns the consolidated object storage on, which the chart needs: artifacts, LFS, uploads, and packages are enabled with no connection of their own. The registry, Pages, and backups keep their own settings in the chart values. |
| Networking | spec.networking | Mode select: Manual (turns the Gateway API off, leaving networking to the chart values below), Envoy Gateway controller (recommended), GatewayClass, or Gateway. The latter two also take an optional “configure Envoy Gateway extensions” toggle, and the first two an optional cert-manager Issuer/ClusterIssuer reference. |
| Version | spec.version | A type and the one field it takes. Static (the default) runs the free-form Chart version, empty by default because the Operator pulls a version it does not carry rather than failing; an upgrade is a change of it. A Static version read from a ConfigMap (versionRef) is shown and kept until a version is typed in its place. Patch follows the latest patch of the Minor (x.y). Latest, Previous, and OldestSupported follow N, N-1, and N-2, counted across majors, within the Major. Every type but Static upgrades the running instance unattended, and a version below the deployed one is refused. |
| Chart values | spec.chart.values | Free-form YAML for everything the fields above do not cover. |
All three connections are required. The chart bundles neither PostgreSQL nor Redis, and it enables object storage for artifacts, LFS, uploads, and packages with no connection of its own, so an instance that leaves a group empty and does not configure it in the chart values fails to render.
The chart values are merged over the values the reconciler derives from the structured fields, and win on conflict. Use them as an escape hatch, and prefer a structured field when one exists.
A group is all or nothing: fill every field of PostgreSQL, Redis, or the license, or leave the group empty. The form reports an incomplete group before it sends the request.
Bridge rejects a value the definition would reject, such as a hostname that is not a domain
name, with a 422 response that names the field. The constraints are part of the OpenAPI document,
so the generated client carries them too.
Configure the Siphon add-on
Siphon streams change data capture from the database of an instance into ClickHouse, through NATS
JetStream. Unlike the other fields of the form, it is a Siphon custom resource of its own, linked
to the instance through spec.gitlabRef. For more information, see
the Siphon reconciler.
In the SPA, select Siphon under Add-ons, then select Enable Siphon. The panel
configures one Siphon resource:
| Field | Resource field | Description |
|---|---|---|
| Chart version | spec.version.version | Version of the Siphon chart, which is not the GitLab chart. Required unless spec.version.versionRef is set, which the form shows and keeps until a version is typed. Only a version the Operator bundles renders, because a Siphon chart is never pulled. |
| PostgreSQL source | spec.source | Hostname, port, database, login role, password Secret, TLS mode, and advisory lock ID of the server the stream reads. It must be the primary. |
| NATS queue | spec.queue | URL of the server, the Secrets holding the user name and password, and the Secret holding a client certificate. Leave the credential fields empty for a server that accepts anonymous clients. |
| ClickHouse sink | spec.sink | Hostname, native protocol port, database, user, password Secret, and TLS of the server the stream is written to. |
| Table definitions | spec.tables | Where the table definitions come from, an image that overrides the one the GitLab version resolves to, and the Secret that image is pulled with. |
The three servers are referenced, not created. The publication, the siphon_alter_publication
function, the login roles and the grants on PostgreSQL, the NATS server, and the ClickHouse target
tables are all prerequisites. For the DDL, see the Siphon reconciler.
Bridge names the resource after the instance, such as gitlab-siphon, and finds it again
through its reference. A Siphon created with kubectl under another name is the one the form
edits from then on. A name longer than the 31 characters the definition allows is rejected with a
422 response.
Saving the form writes the instance first, then the Siphon. Clearing Enable Siphon deletes the
resource after a confirmation. If the Siphon cannot be written, the instance is still saved. The
form stays open on the add-on, and saving again updates that instance and retries the Siphon. The
form does not create the instance a second time.
If the caller cannot read Siphon resources, for example because their role covers only
gitlabcores.apps.gitlab.com, the instance can still be edited and saved. The add-on is locked
and marked Unknown, and saving leaves any existing Siphon untouched.
Deleting a Siphon stops the pipeline and leaves its PostgreSQL publication and replication slot
and its NATS stream behind. A retained replication slot pins write-ahead log on the source server,
which eventually fills its volume. The panel reports all three while the resource exists.
Verify the RBAC delegation
To confirm Bridge uses the caller identity rather than the operator identity, use a token whose
service account lacks a verb. For example, a read-only account that attempts a create receives a
403 response:
# A read succeeds.
curl -H "Authorization: Bearer $TOKEN" localhost:8090/api/v1/gitlabs
# A create the caller cannot perform returns HTTP 403.Run Bridge as a kubectl plugin
The kubectl bridge plugin runs Bridge on your own machine instead of in the cluster. The plugin
builds its Kubernetes client from your kubeconfig, so it authenticates the same way kubectl does.
Client certificate, exec, OIDC, and token kubeconfigs all work. You do not create a service account,
mint a token, or paste anything into the UI.
Use the plugin when you want the UI without deploying a Bridge image, or when your kubeconfig cannot be reduced to a bearer token.
The plugin performs no authentication of its own. Anyone who reaches the port acts with your kubeconfig permissions. It binds loopback by default. You can bind a routable address, for example to run the plugin in a container, but the plugin prints a warning because that exposes full cluster access to the network.
The plugin answers 403 to an /api request whose Host is neither a loopback name, such as
localhost, 127.0.0.1, or [::1], nor the bind address, nor one you passed to --accept-hosts, and to one that a browser marks as cross-site. The SPA, the docs UI, and
clients such as curl are unaffected. To reach the plugin under another name, such as a DNS entry
or a container host name, pass that name to --accept-hosts. For why, see
ADR 34.
Install the plugin
Build and install the binary. The task builds the SPA first, then runs go install, which compiles
the SPA into the binary with go:embed:
task install-kubectl-pluginThe binary goes where go install puts it: $GOBIN when set, otherwise $(go env GOPATH)/bin. The
task prints the resolved path.
That directory must be on your PATH, because kubectl discovers plugins by searching PATH for
executables named kubectl-<name>. Add it to your shell profile if needed:
export PATH="$(go env GOPATH)/bin:$PATH"Confirm kubectl found the plugin:
kubectl plugin list | grep kubectl-bridgeTo build the binary into bin/kubectl-bridge without installing it, use task build-kubectl-plugin.
Start the plugin
Run the plugin against your current kubeconfig context:
kubectl bridgeThe plugin prints the identity it acts as and the URL, then opens the URL in your browser. The header shows Local — kubeconfig identity instead of the token field. Stop the plugin with Control+C.
Plugin flags
| Flag | Default | Description |
|---|---|---|
--port, -p | 8090 | Local port to bind. Falls back to a random free port when the port is busy. |
--address | 127.0.0.1 | Address to bind. IPv6 literals such as ::1 work. A non-loopback address is allowed, with a warning. |
--accept-hosts | Loopback names | Comma-separated Host header names to accept on /api besides loopback ones and the bind address. Needed to reach the plugin under another name. |
--context | Current context | Kubeconfig context to use. |
--kubeconfig | Standard loading rules | Path to the kubeconfig file. |
--no-open | Off | Print the URL instead of opening a browser. |
--verbose, -v | Off | Log startup details and every served request at debug level. |
For example, to use a different context on another port without opening a browser:
kubectl bridge --context staging --port 9000 --no-openFrontend development
To work on the SPA with hot-module reload, run Bridge so :8090 is reachable. Use
ENABLE_BRIDGE=true task run_bridge or a port-forward. Then start the Vite dev server:
task frontend-dev # http://localhost:5173, proxies /api, /openapi*, and /docs to :8090For more information about the frontend workflow and regenerating the OpenAPI document and typed client, see internal/bridge/AGENTS.md.