> For the complete documentation index, see [llms.txt](https://docs-lunar.earthly.dev/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs-lunar.earthly.dev/install/git-platforms/gitlab.md).

# GitLab

Set up GitLab for Lunar, including the Hub service account, user sign-in, result reporting, and merge blocking.

The Hub authenticates as a **service account**: a machine user that you create, grant group memberships to, and issue a token for.

This page covers the GitLab side only. The install steps that consume it are in [Prerequisites → Step 5](/install/lunar-hub/self-hosted/prerequisites.md#step-5-connect-your-git-platform) for self-hosted, or the [Dedicated](/install/lunar-hub/dedicated/setup.md) setup steps.

## Authentication

How many accounts and tokens you need depends on where your GitLab runs:

| Your GitLab                      | Service account                                                                            | Tokens                       |
| -------------------------------- | ------------------------------------------------------------------------------------------ | ---------------------------- |
| GitLab Dedicated or self-managed | A single **instance service account**, made a member of every top-level group Lunar serves | **One token** for everything |
| GitLab.com                       | One **group service account** per top-level group                                          | One token per group          |

Prefer the single instance service account wherever you administer the instance. GitLab.com has no such thing, so each top-level group there brings its own account and token.

With an instance service account you do not need to tell Lunar which groups it serves. It reads that from the account's memberships: every group where the account holds the Maintainer role, each covering its whole subtree. Lunar follows those memberships as they change, so you bring a group into scope by inviting the service account to it. A group service account's token, by contrast, is bound to its group in the Hub configuration (the `group` field below), and Lunar only uses it there. Where several tokens are configured for the same scope, Lunar load-balances its traffic across them (see [More rate limit for a busy scope](#more-rate-limit-for-a-busy-scope)).

### Create the service account

On **GitLab Dedicated or self-managed**, an instance administrator does this once:

1. Go to **Admin → Settings → Service accounts** and select **Add service account**.
2. Name it something recognizable, e.g. `Earthly Lunar`. The account appears as the author of Lunar's merge-request comments and commit statuses.
3. Add the account to **every top-level group** Lunar should serve, with the **Maintainer** role (each group's **Manage → Members → Invite members**). A membership covers all of the group's subgroups and projects, and it is how Lunar decides what to serve. Inviting the account to a new group later onboards that group without any configuration change.

On **GitLab.com**, a group Owner does it once per top-level group:

1. In the group, go to **Settings → Service accounts** and select **Add service account**.
2. Add the account to the group with the **Maintainer** role.

### Create the token

On the service accounts page, select the vertical ellipsis (**⋮**) next to the account, then **Manage access tokens → Add new token**. Give it the `api` scope, and pick an expiry (see [Expiry and rotation](#expiry-and-rotation) below).

What each token can do decides which features are available for the groups it serves:

| Permission | Value        | What it enables                                                                                                                                                                                          |
| ---------- | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| scope      | `api`        | Everything Lunar does over the API: reading repositories, commits, and merge requests; posting commit statuses and MR comments                                                                           |
| role       | `Maintainer` | Registering project webhooks, maintaining [project badges](#project-badges), and on Ultimate provisioning the [merge gate](#merge-gate): the status check and the **Status checks must succeed** setting |

Copy the token from GitLab. You leave this section with either a **single token** (instance service account) or **one token per top-level group** (GitLab.com).

### Expiry and rotation

GitLab access tokens carry a fixed expiry (by default at most a year out), and the Hub reads each token once at startup.

Set a calendar reminder ahead of the expiry date. When a token lapses, Lunar stops posting results and stops reacting to webhooks for the groups it served, and because the Hub only reads tokens at startup, nothing fails loudly to tell you.

A service account can hold several tokens at once, so replacement is a rollover rather than a cutover: create the new token next to the old one on the **Manage access tokens** page, deliver it, and revoke the old one once the Hub is running with the new. The delivery mechanics depend on your install: [self-hosted](/install/lunar-hub/self-hosted/day-2-operations.md#git-platform-credentials) is a secret update plus a Hub restart; on [Dedicated](/install/lunar-hub/dedicated/setup.md#step-4-deposit-your-secrets) you re-deposit the token into your install's secret drop.

{% hint style="warning" %}
GitLab 19.2 added an instance setting that **enforces fine-grained tokens after a chosen date**, which blocks the creation and rotation of classic-scope tokens. Service accounts cannot use fine-grained tokens yet, so if your instance plans to turn that enforcement on, keep its date clear of Lunar's rotation schedule until GitLab extends them to service accounts.
{% endhint %}

## User sign-in application

For [`lunar login`](/docs/lunar-cli.md#lunar-login), create a non-confidential group or instance application with scopes `openid read_user read_repository read_api` and redirect URI `http://127.0.0.1/callback`. On GitLab 18.11+, enable **Device authorization grant**; versions 17.9–18.10 enable it instance-wide, while older versions fall back to PKCE.

For self-hosted, add the application ID to [`HUB_AUTH_OAUTH_APPS`](/install/lunar-hub/self-hosted/install-walkthrough.md#user-logins). For Lunar Dedicated, send Earthly the application ID and the hostname of a self-managed instance during [setup](/install/lunar-hub/dedicated/setup.md#step-5-set-up-user-sign-in).

### Dashboard sign-in application

When the Hub signs people in to the dashboards ([self-hosted](/install/lunar-hub/self-hosted/install-walkthrough.md#signing-in-through-the-hub), [Dedicated](/install/lunar-hub/dedicated/setup.md#step-5-set-up-user-sign-in)), it can share the `lunar login` application above, but GitLab then shows its authorization page on every sign-in. GitLab remembers a grant only for **confidential** applications, and the `lunar login` application must stay non-confidential because the CLI cannot hold a secret.

To sign in without the page, create a second application in the same group or instance, this one **confidential**, with scope `read_user` and redirect URI `<grafana-url>/oauth/callback`, where `<grafana-url>` is the address people open the dashboards at (`grafana.url` in the chart; the install's apex hostname on Dedicated). Keep its application ID and secret for the Hub: self-hosted, under `grafana.sso.forgeApp`; on Dedicated, send Earthly the ID and deposit the secret in [step 4](/install/lunar-hub/dedicated/setup.md#step-4-deposit-your-secrets). Do not add it to `HUB_AUTH_OAUTH_APPS`.

On a self-managed or Dedicated instance, an administrator can instead mark an instance-wide application **Trusted** in the Admin area, which skips the page whether or not the application is confidential; a group-owned application cannot be marked.

## Hub configuration (Dedicated)

There is nothing to configure: Earthly runs the Hub and configures it for you. Continue at [Dedicated → Deposit your credentials](/install/lunar-hub/dedicated/setup.md#step-4-deposit-your-secrets), where the token goes to your install rather than to Earthly. What you deposit is what the steps above produced:

* **GitLab Dedicated or self-managed**: the instance service account's single token, alongside your instance hostname.
* **GitLab.com**: one token per **group service account**.

Everything below is for self-hosted installs; you can skip to [Merge gate](#merge-gate).

## Hub configuration (self-hosted)

Lunar reads GitLab credentials from `HUB_GITLAB_TOKENS`, a JSON array with one entry per token, typically a single entry holding the instance service account's token. [Install Step 4](/install/lunar-hub/self-hosted/install-walkthrough.md#git-platform-credentials) has the values block to paste; this is the field reference behind it.

### Entry fields

| Field                      | Required | Meaning                                                                                                                                                                                                                                                                                                                                           |
| -------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `token_path`               | Yes      | Where the token file is mounted inside the Hub container.                                                                                                                                                                                                                                                                                         |
| `group`                    | No       | Binds the token to one top-level group or subgroup, which it serves together with everything beneath it. Omit it for an instance service account: the token then serves every group on `host` that no `group` entry claims. A `group` entry always takes precedence over a group-less one, and the most specific `group` wins when several match. |
| `webhook_secret`           | No       | Overrides the chart-generated signing secret (`<release>-gitlab-webhook`) for the groups this entry serves. Set it when one group should not be able to forge deliveries for another; leave it out otherwise. Entries that form a pool (same host and `group`) must agree on it; the hub refuses to start otherwise.                              |
| `previous_webhook_secrets` | No       | Older values of `webhook_secret` still accepted on incoming deliveries, for rotating it without rejecting hooks that carry the old value. Never set on new hooks. Planned rotations only, never a secret you suspect has leaked.                                                                                                                  |
| `host`                     | No       | Defaults to `gitlab.com`. Set it to your instance hostname for self-managed.                                                                                                                                                                                                                                                                      |
| `base_url`                 | No       | Defaults to `https://<host>/api/v4`. Set it only if your instance serves the API somewhere else.                                                                                                                                                                                                                                                  |

A token without `group` must be a Maintainer everywhere Lunar reaches on that host: Lunar does not fall back to another token when a call it makes with that account is refused. On GitLab.com, where each token belongs to a group service account, set `group` on every entry.

{% hint style="warning" %}
**A self-managed host must be listed here.** Lunar decides whether a host is GitLab from this configuration. If you omit `host` for a self-managed instance, Lunar treats that host as GitHub, and every operation against it fails in confusing ways. `gitlab.com` is recognized without configuration.
{% endhint %}

### Multiple groups

What this looks like depends on which GitLab you run.

#### GitLab.com: one entry per group

There is no instance-wide service account on GitLab.com, so every top-level group brings its own account and its own `group` entry:

```yaml
hub:
  extraEnv:
    - name: HUB_GITLAB_TOKENS
      value: >-
        [{"group":"acme","token_path":"/secrets/gitlab/acme.token"},
         {"group":"globex","token_path":"/secrets/gitlab/globex.token"}]
```

#### GitLab Dedicated or self-managed

The instance service account serves every group it is a Maintainer of, so one group-less entry covers them all. Invite the account to a new group and nothing here changes:

```yaml
hub:
  extraEnv:
    - name: HUB_GITLAB_TOKENS
      value: '[{"host":"gitlab.example.com","token_path":"/secrets/gitlab/lunar-sa.token"}]'
```

One group can still take a token of its own, for example a sensitive group that should show a separate bot in its audit trail. A `group` entry wins over the group-less one, so the shared account never touches that group:

```yaml
hub:
  extraEnv:
    - name: HUB_GITLAB_TOKENS
      value: >-
        [{"host":"gitlab.example.com","token_path":"/secrets/gitlab/lunar-sa.token"},
         {"host":"gitlab.example.com","group":"finance","token_path":"/secrets/gitlab/finance-bot.token"}]
```

### More rate limit for a busy scope

Repeating a scope (the same `group`, or the same `host` with no `group`) pools its tokens: Lunar spreads read traffic (commits, merge requests, project metadata, and the lookups that precede its own posts) across the pool, and with it the writes GitLab does not tie to the account that made them: merge request notes, external status check responses and project badges may come from any account in the pool. Commit statuses, webhooks and bypass replies always come from the first entry listed, because GitLab keys a status transition on the account that posted it and a hook carries one secret. To give a busy subgroup more rate limit without changing who serves the rest of the instance, add a second account to that subgroup and list both tokens under its `group`:

```yaml
hub:
  extraEnv:
    - name: HUB_GITLAB_TOKENS
      value: >-
        [{"host":"gitlab.example.com","token_path":"/secrets/gitlab/lunar-sa.token"},
         {"host":"gitlab.example.com","group":"platform/checkout","token_path":"/secrets/gitlab/lunar-sa.token"},
         {"host":"gitlab.example.com","group":"platform/checkout","token_path":"/secrets/gitlab/checkout-sa.token"}]
```

Here `platform/checkout` draws on both accounts' rate limits, and everything else stays on the shared account alone. Give the accounts in a pool the same role, so that what Lunar can do in a group does not depend on which token serves a call: in particular, every account needs the Maintainer role to edit a merge request note another one wrote.

{% hint style="info" %}
**Scaling tip.** GitLab applies API rate limits per account. If the whole instance is busy, add a second instance service account as another group-less entry; if one group or subgroup pushes constantly, pool extra tokens under its `group` as above. (On a self-managed instance you can also raise the limits; on gitlab.com you cannot.)
{% endhint %}

## Merge gate

Lunar can block a merge request on every tier. What differs is the mechanism, how precisely it targets Lunar, and who switches it on.

| Tier          | Mechanism                                                 | Blocking                                                                                                                            |
| ------------- | --------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| Ultimate      | [External status check](#external-status-checks-ultimate) | Lunar provisions and enables it for you                                                                                             |
| Free, Premium | [Commit status](#commit-status-default)                   | Available, but you enable **Pipelines must succeed** yourself, and it gates on the whole pipeline rather than on Lunar specifically |

Only policies at a gating enforcement level participate. See [policies](/configuration/lunar-config/policies.md). Whichever mechanism you are on, a blocked merge request can be let through; see the [PR/MR comments reference](/docs/pr-comments.md).

### External status checks (Ultimate)

On **GitLab Ultimate**, Lunar blocks a merge request whose gating policies do not pass.

{% hint style="warning" %}
**There is no setting to turn this on, and Lunar changes your projects to enable it.** Read what it changes below before rolling Lunar out to an Ultimate namespace.
{% endhint %}

Merge blocking is capability-driven: Lunar detects that a namespace is on Ultimate and switches on automatically. There is no configuration flag, so if you upgrade a namespace's licence later, blocking activates on the next sync without anyone touching Lunar's configuration.

To make blocking work, Lunar changes each project it tracks:

| Change                                       | Where you see it in GitLab                    |
| -------------------------------------------- | --------------------------------------------- |
| Creates a status check named `Earthly Lunar` | **Settings → Merge requests → Status checks** |
| Enables **Status checks must succeed**       | **Settings → Merge requests**                 |

**Lunar re-applies these settings on every sync.** Turning them off in the GitLab UI is temporary; they come back. This is deliberate: it keeps the gate from being quietly disabled on one project and drifting out of policy. But it does mean that switching the gate off is not a supported way to stop it; see [Turning the gate off](#turning-the-gate-off).

This requires the service account to hold the `Maintainer` or `Owner` role, since Lunar creates the status check and changes merge-request settings.

#### Turning the gate off

The gate is **fail-closed**. If Lunar is unavailable, gated merge requests stay blocked rather than falling open. That is the safe default for a compliance control, but it means you should know the levers before you need them.

To unblock merges during an incident, a project **Owner** disables **Status checks must succeed** in **Settings → Merge requests**. This is the one lever that works while Lunar itself is down; the others below are applied by the Hub.

To stop gating a project for good, remove its components from the catalog. Lunar deletes the `Earthly Lunar` status check it created there, and merge requests on that project are no longer gated by Lunar.

To pause the gate for a while, on every project or on one group, run [`lunar hub merge-gate off`](/docs/lunar-cli.md#lunar-hub-merge-gate) with a duration and a reason. It takes the [`hub.operate`](/docs/authorization.md#hub-operate) permission, needs no redeploy, is written to an audit ledger and always expires; `lunar hub merge-gate on` ends it early, or pins one group back on underneath a broader off, which is how to roll the gate out group by group. It also removes Lunar's project badges until the gate is back.

To pause the gate everywhere with no expiry, set `HUB_GITLAB_MERGE_GATE_ENABLED=false` on the Hub (a Hub environment variable on self-hosted; on Lunar Dedicated, ask Earthly). The Hub removes the `Earthly Lunar` check from every project and, when the variable is back, re-creates it and answers `passed` for every open merge request; what to expect on the way, and why never to re-create the check by hand, is in [day-2 operations](/install/lunar-hub/self-hosted/day-2-operations.md#operating-the-gitlab-merge-gate).

Renaming or transferring a gated project is safe. Until it is cataloged again under its new path, Lunar answers its merge requests `passed` (it has no components to evaluate there), so nothing is blocked by the check that moved with it. Re-catalog it under the new path and the existing check and webhook are picked up as they are; remove its components instead and Lunar follows the project to its new location and deletes the check there. A deleted project's check goes when GitLab purges the project.

### Commit status (default)

On **GitLab Free and Premium** there are no external status checks, so Lunar posts a commit status named `Earthly Lunar` instead. Lunar provisions no merge gate on these tiers, and changes no merge-request setting. It still writes the status, registers the project webhook, and maintains [project badges](#project-badges), as it does on Ultimate.

A commit status folds into the commit's pipeline result, so it **can** gate merges, but you enable that yourself, per project, in **Settings → Merge requests → Merge checks → Pipelines must succeed**. With it on, a failing Lunar status makes the pipeline fail and the merge request unmergeable.

Two consequences worth knowing before relying on it:

* **It is not Lunar-specific.** The setting gates on the pipeline as a whole, so a Lunar failure and a broken test look the same to it, and you cannot require Lunar without also requiring everything else in the pipeline. The Ultimate status check is a named requirement; this is not.
* **Pending counts as not-succeeded.** Lunar reports `running` while a commit's checks are still evaluating, so a merge request stays unmergeable until Lunar resolves. That is the point, and it is why [a bypass](/docs/pr-comments.md) forces the status green for a commit rather than waiting for the evaluation to finish.

## MR status reporting

On a merge request, Lunar reports each evaluation as:

* **A named result**: the `Earthly Lunar` status check on Ultimate, or a commit status on Free and Premium. See [Merge gate](#merge-gate) for which of those can block.
* **A merge-request comment** summarising the policy results, updated in place as new commits arrive rather than posted repeatedly.

Reporting depends on webhooks, which the Hub registers on each project automatically, on every tier. The hook subscribes to push, merge-request, and comment events; the comment events are what carry the [`/lunar` commands](/docs/pr-comments.md).

## Main branch status reporting

On commits to the default branch, Lunar posts a **commit status**, on every tier.

Status checks exist only on merge requests, so there is no Ultimate-specific behaviour here and nothing to configure: default-branch reporting looks the same whichever tier you are on. These statuses are informational; there is no merge to block.

### Project badges

Lunar maintains **project badges** that reflect the latest default-branch result, so a project's standing is visible from its overview page without opening a commit or a dashboard. Each links to the dashboard behind it.

* **Release readiness**, one per project. It reads `release ready` when nothing blocks a release, `release blocked` when something does, `release bypassed` when the only things blocking are covered by an active [`lunar policy bypass-release`](/docs/lunar-cli.md#lunar-policy-bypass-release), and `evaluating` while results are still arriving. A partial bypass still reads `release blocked`.
* **One per initiative**, showing that initiative's score for the component.

Lunar gates releases but never observes one, so `release ready` means nothing is blocking, not that anything shipped. A component with no release-gating policies reads `release ready`.

The default-branch commit status is deliberately left at its un-bypassed verdict, so a bypassed project reads as blocked on the commit and overridden on the badge.

On a monorepo, the badges belong to the component whose name carries no subdirectory, and the release badge is omitted, since release readiness is per component and the project has several. A monorepo with no whole-repo component gets no badges.

Lunar provisions badges with the service account's token, as it does the merge gate's status check, so the account needs the same `Maintainer` or `Owner` role.

Badges have a fleet-wide switch of their own, `HUB_GITLAB_BADGES_ENABLED`. Off, the Hub removes every badge it created from every project, at the same pace as the merge gate's switch, and posts no more; on again, they return on each project's next evaluation. It is held by whoever holds the merge gate's switch on your install.

## Next steps

Self-hosted installs carry the token into a Kubernetes secret at [prereqs Step 6](/install/lunar-hub/self-hosted/prerequisites.md#step-6-plan-your-kubernetes-secrets), and into your chart values at [install Step 4](/install/lunar-hub/self-hosted/install-walkthrough.md#git-platform-credentials). On Dedicated you [deposit it](/install/lunar-hub/dedicated/setup.md#step-4-deposit-your-secrets) into your install's secret drop instead.

After that, set up [config sync from GitLab CI](/install/lunar-hub/self-hosted/sync-config.md#gitlab-ci) so the Hub's copy of your configuration stays current as you edit it. [Git Platforms](/install/git-platforms.md) has the full capability comparison with GitHub.


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation by asking a question.

Perform an HTTP GET request on the following URL with the `ask` and `goal` query parameters:

```
GET https://docs-lunar.earthly.dev/install/git-platforms/gitlab.md?ask=<question>&goal=<user_goal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is what the user is ultimately trying to achieve, the reason they need the answer. Sharing it helps GitBook give you a better, more relevant answer. A goal is most helpful when it describes the outcome the user wants rather than restating the question. For example, with `ask=how do I create an API token`, a goal like `automate deployments from our CI pipeline` lets GitBook tailor the answer to that use case.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
