> 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/lunar-hub/self-hosted/install-walkthrough.md).

# Install Walkthrough

This walkthrough takes you from zero to a working Lunar Hub. It assumes you've worked through the [prerequisites](/install/lunar-hub/self-hosted/prerequisites.md), and have:

* Kubernetes cluster with your namespaces created
* Postgres database
* two S3 buckets
* GitHub App
* hub licence key
* DNS name for the Hub

Assuming your prerequisites are in order, expect the install to take 15–30 minutes end to end. The bulk of that is waiting for external systems (e.g. first-boot migrations).

## Step 1 — Add the Helm repo

```bash
helm repo add earthly https://earthly.github.io/charts
helm repo update
```

Verify:

```bash
helm search repo earthly/lunar
```

You should see the chart listed with a version number.

## Step 2 — Verify your prerequisites

Before going further, sanity-check the external dependencies you provisioned in the [prerequisites](/install/lunar-hub/self-hosted/prerequisites.md). An issue here is cheaper to find now than after you've created secrets and written your `values.yaml`.

**DNS** ([prereqs Step 2](/install/lunar-hub/self-hosted/prerequisites.md#step-2-check-your-kubernetes-cluster)):

```bash
nslookup lunar.example.com
```

Should resolve to your ingress controller's external IP. If it doesn't yet, either wait or add an `A` / `CNAME` record now — GitHub webhook auto-registration needs a publicly reachable URL.

**Postgres reachability** ([step 3](/install/lunar-hub/self-hosted/prerequisites.md#step-3-provision-postgresql)):

```bash
kubectl run -it --rm --restart=Never -n lunar pg-check \
  --image=postgres:16 -- \
  psql "postgresql://<user>:<pass>@<host>:5432/lunar" \
  -c "SELECT 1;"
```

Should return `1`. If the connection fails or the role is rejected, fix that before installing — the Hub will crash-loop otherwise.

**S3 access** ([prereqs Step 4](/install/lunar-hub/self-hosted/prerequisites.md#step-4-provision-s3-compatible-object-storage)):

```bash
aws s3api head-bucket --bucket your-lunar-logs-bucket
aws s3api head-bucket --bucket your-lunar-resources-bucket
```

Both should succeed silently (exit code `0`). This requires the AWS CLI with credentials configured on your workstation — it confirms the buckets exist and that *some* credentials can reach them, but doesn't validate the in-cluster IRSA path. If your workstation isn't AWS-configured, skip this check; Hub logs surface S3 misconfiguration immediately on install.

## Step 3 — Create Kubernetes secrets

You'll need to create three Kubernetes secrets yourself:

* Database credentials
* Your Git platform credential: the GitHub App PEM, or the GitLab group access token, from [prereqs Step 5](/install/lunar-hub/self-hosted/prerequisites.md#step-5-connect-your-git-platform)
* A signed Hub licence JWT (from your Earthly contact)

Other secrets, like the Hub service token, the webhook signing secrets, and the Grafana admin password, are auto-generated by the chart on first install and preserved across upgrades. You'll retrieve their values after install in [Step 6](#step-6-verify-the-install).

Create the three with `kubectl`, or provision them with your secret manager of choice, using the same names and keys as specified below.

**Database credentials:**

```bash
kubectl -n lunar create secret generic lunar-db \
  --from-literal=username='<db-user>' \
  --from-literal=password='<db-password>'
```

**Git platform credential** — create the one that matches your platform, or both if you run both:

{% tabs %}
{% tab title="GitHub" %}
Base64-encode the App's PEM and store it as the `private-key` field:

```bash
kubectl -n lunar create secret generic lunar-github-app \
  --from-literal=private-key="$(base64 < path/to/lunar-github-app-<id>.pem | tr -d '\n')"
```

{% hint style="info" %}
The PEM must be base64-encoded inside the secret value — the Hub decodes it after reading the env var. (Kubernetes then base64-encodes the whole secret again for etcd storage, so it's double-encoded at rest.)
{% endhint %}
{% endtab %}

{% tab title="GitLab" %}
The Hub reads the group access token from a mounted file, so it goes in as plain text with no base64 step:

```bash
kubectl -n lunar create secret generic lunar-gitlab-token \
  --from-literal=token='<your-group-access-token>'
```

Step 4 mounts this secret at the `token_path` you declare in `HUB_GITLAB_TOKENS`.
{% endtab %}
{% endtabs %}

**Hub licence JWT** — store the signed token in `hub-licence.jwt`:

```bash
kubectl -n lunar create secret generic lunar-hub-licence \
  --from-literal=hub-licence.jwt='<signed-licence-jwt>'
```

If your collectors need credentials at runtime (`github.*` collectors, Datadog, Jira, Linear, etc.), provision a fourth secret as well — see [Script runtime secrets](#script-runtime-secrets) at the bottom of this page.

## Step 4 — Write `values.yaml`

Create a `values.yaml` with the minimum configuration the chart needs. The example below is a working baseline — copy it, fill in the placeholders, and adjust for your environment. Its `hub.github` block assumes GitHub; [Git platform credentials](#git-platform-credentials) below covers what to change on GitLab.

```yaml
# Cluster assumptions baked into this sample:
#   - NGINX ingress controller    → hub.ingress.className + grpcAnnotations
#   - cert-manager for TLS        → ingress annotations (drop if BYO secret)
#   - EKS IRSA for S3 credentials → serviceAccount.annotations (see "AWS credentials" below)

# IRSA on EKS: annotate the service account with your IAM role ARN.
# See "AWS credentials" below for non-EKS options.
serviceAccount:
  annotations:
    eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/lunar-hub-s3

hub:
  publicBaseURL: "https://lunar.example.com"
  licence:
    secretName: "lunar-hub-licence"
    secretKey: "hub-licence.jwt"

  # Run the Hub as multiple replicas. The Hub is stateless — all state lives
  # in Postgres and S3 — so replicas coordinate through Postgres (leader
  # election, advisory locks) with no peer-to-peer wiring. See "Replicas and
  # the Postgres connection budget" below before raising this.
  replicaCount: 3
  # Keep a quorum during node drains and rollouts. Enable this whenever
  # replicaCount > 1 (a PDB on a single replica blocks voluntary node drains).
  podDisruptionBudget:
    enabled: true
    maxUnavailable: 1

  # Pin all four image tags (hub, operator, initImage, sidecarImage) to the
  # same released hub version for a reproducible install. The latest is
  # published to ghcr.io/earthly/lunar-hub — see the repo's `lunar-hub-v*`
  # release tags. This example is bumped automatically on each Hub release.
  image:
    tag: "4.12.0"

  db:
    host: "your-db-host.example.com"
    name: "lunar"

  s3:
    logsBucket: "your-lunar-logs-bucket"
    resourcesBucket: "your-lunar-resources-bucket"

  github:
    app:
      # Don't forget the quotes, otherwise they might get rendered as scientific notation!
      id: "123456"
      installId: "78901234"
      # The GitHub org (or user) the App is installed on. Required since chart 2.2.0.
      owner: "<your-github-org>"

  # AWS region for S3. Credentials themselves come from the AWS SDK
  # credential chain (IRSA above, or see "AWS credentials" below).
  extraEnv:
    - name: AWS_REGION
      value: us-east-1

  # The chart sets no resource defaults. Numbers below are the starting
  # recommendations from prereqs Step 7 — tune as you observe real load.
  resources:
    requests:
      cpu: 500m
      memory: 1Gi

  ingress:
    enabled: true
    host: lunar.example.com
    className: nginx
    tls:
      - secretName: lunar-tls
        hosts: [lunar.example.com]
    annotations:
      # cert-manager provisions the `lunar-tls` secret automatically.
      # Remove this annotation if you supply the TLS secret yourself
      # (corporate CA, external LB, ACM, etc.).
      cert-manager.io/cluster-issuer: letsencrypt
    # gRPC backend-protocol applies only to the gRPC Ingress. Both
    # Ingresses are always rendered when ingress.enabled is true.
    grpcAnnotations:
      nginx.ingress.kubernetes.io/backend-protocol: "GRPC"

operator:
  # Run pods are created here. The namespace must already exist; the chart
  # will not create it. See prereqs Step 1 for the trust-boundary rationale.
  snippetNamespace: "lunar-scripts"

  image:
    tag: "4.12.0"
  initImage:
    tag: "4.12.0"
  sidecarImage:
    tag: "4.12.0"

  resources:
    requests:
      cpu: 100m
      memory: 128Mi

# Grafana — the primary UI for Lunar. Enabled by default: the chart runs a
# stock Grafana and installs Lunar's dashboards into it. This block just wires
# up its ingress. If you'd rather use a Grafana you already run (Cloud,
# Enterprise, or self-hosted OSS), drop this block and see the "Grafana"
# section below for how to point Lunar at it instead.
grafana:
  ingress:
    enabled: true
    hosts: [grafana.lunar.example.com]
    tls:
      - secretName: lunar-grafana-tls
        hosts: [grafana.lunar.example.com]
    annotations:
      cert-manager.io/cluster-issuer: letsencrypt
```

Everything else has a sensible default. See the [chart README](https://github.com/earthly/charts/blob/main/README.md#values-reference) for the full values reference.

### Git platform credentials

Serving both platforms from one Hub is supported: configure both, and each repository is routed by its host.

{% tabs %}
{% tab title="GitHub" %}
The `hub.github.app` block in the sample above is the whole configuration. Its `id`, `installId`, and `owner` come from [prereqs Step 5](/install/lunar-hub/self-hosted/prerequisites.md#step-5-connect-your-git-platform); the private key comes from the `lunar-github-app` secret created in Step 3. All four are required together; a partial App configuration is rejected at startup.

On **GitHub Enterprise Server**, also point the Hub at your instance:

```yaml
hub:
  github:
    baseUrl: https://github.example.com
```

Fronting several GitHub orgs from one Hub takes a different shape. See [GitHub → multiple organizations](/install/git-platforms/github.md#multiple-organizations).
{% endtab %}

{% tab title="GitLab" %}
The chart has dedicated values for the GitHub App (`hub.github.app.*`) but not yet for GitLab, so GitLab credentials go in through the generic passthroughs: the environment variable via `hub.extraEnv`, and the token file via a mounted secret.

```yaml
hub:
  extraEnv:
    # ... alongside AWS_REGION from the sample above
    - name: HUB_GITLAB_TOKENS
      value: '[{"group":"acme","host":"gitlab.com","token_path":"/secrets/gitlab/token"}]'

  volumeMounts:
    - name: gitlab-token
      mountPath: /secrets/gitlab
      readOnly: true
  volumes:
    - name: gitlab-token
      secret:
        secretName: lunar-gitlab-token
```

One entry per token; `token_path` must match the `mountPath` above. `group` binds the token to that group and its subtree, which is what a GitLab.com group service account needs; leave it out for an instance service account on self-managed, and the token serves every group the account is a member of. The webhook signing secret is not in there: the chart generates it, the same as it does for GitHub. [GitLab → Hub configuration](/install/git-platforms/gitlab.md#hub-configuration-self-hosted) has the full field reference and the multi-group form.

{% hint style="warning" %}
**Keep the `hub.github` block even on a GitLab-only install.** The Hub itself needs only one Git platform configured, but the chart does not yet know that: its template guard requires the GitHub App values and aborts the render with `hub.github.app.id is required (numeric, non-zero)` when they are absent. Until the chart gains dedicated GitLab values, a chart install has to carry a GitHub App configuration alongside `HUB_GITLAB_TOKENS`.
{% endhint %}
{% endtab %}
{% endtabs %}

### Hub service token

`HUB_AUTH_TOKEN` sets the shared operator credential, and `HUB_AUTH_TOKEN_TTL` sets its lifetime (`0` never expires). The chart generates and injects the token automatically. `HUB_AUTH_TOKEN_PREVIOUS_VALID_FOR` sets how long a replaced token keeps working after a [rotation](/install/lunar-hub/self-hosted/day-2-operations.md#hub-auth-token-release-auth-token) (default `168h`; `0` rejects it immediately).

{% hint style="warning" %}
**Do not use a JWT-shaped service token.** JWT-shaped values are routed to JWT verification and never retried as service tokens; generate one with `openssl rand -hex 32`.
{% endhint %}

### CI job identity

To use [CI job identity](/docs/authorization/ci-identity.md), configure:

| Variable                    | Default                            | Purpose                                                                    |
| --------------------------- | ---------------------------------- | -------------------------------------------------------------------------- |
| `HUB_AUTH_AUDIENCE`         | host part of `HUB_PUBLIC_BASE_URL` | Bare host required in the token's `aud`; empty disables JWT credentials    |
| `HUB_AUTH_SESSION_KEY_PATH` | *(unset)*                          | Shared file containing at least 32 random bytes; required to mint sessions |
| `HUB_AUTH_CI_SESSION_TTL`   | `6h`                               | Session lifetime                                                           |
| `HUB_LUNAR_CONFIG_REPO`     | *(unset)*                          | Configuration repository allowed to bootstrap with CI identity             |

Trusted issuers are `token.actions.githubusercontent.com` for github.com, `https://<host>/_services/token` for each configured GitHub Enterprise Server, and every GitLab host in `HUB_GITLAB_TOKENS`; other issuers are rejected.

Create the key once and mount it the same way as the GitLab token file:

```bash
openssl rand -base64 48 | kubectl -n lunar create secret generic lunar-auth-session-key \
  --from-file=session-key=/dev/stdin
```

```yaml
hub:
  extraEnv:
    - name: HUB_AUTH_SESSION_KEY_PATH
      value: /secrets/auth/session-key
  volumeMounts:
    - name: auth-session-key
      mountPath: /secrets/auth
      readOnly: true
  volumes:
    - name: auth-session-key
      secret:
        secretName: lunar-auth-session-key
```

### User logins

Create a [GitHub sign-in app](/install/git-platforms/github.md#user-sign-in-app) or [GitLab sign-in application](/install/git-platforms/gitlab.md#user-sign-in-application) on each host, then configure:

```yaml
hub:
  extraEnv:
    - name: HUB_AUTH_OAUTH_APPS
      value: '[{"forge":"github","host":"github.com","client_id":"Iv1.0123456789abcdef"},{"forge":"gitlab","host":"gitlab.com","client_id":"<application-id>"}]'
```

Each entry requires a Git platform (`github` or `gitlab`) and `client_id`; `host` defaults to `github.com` or `gitlab.com`. User sessions also require `HUB_AUTH_SESSION_KEY_PATH` and a non-empty `HUB_AUTH_AUDIENCE`, which normally comes from the host in `HUB_PUBLIC_BASE_URL`.

Without a GitHub client secret, `lunar logout` prints the manual revocation URL. To let it revoke the token automatically, create and mount the secret:

```bash
kubectl -n lunar create secret generic lunar-github-oauth \
  --from-file=client-secret=<path/to/github-client-secret>
```

```yaml
hub:
  volumeMounts:
    - name: github-oauth
      mountPath: /secrets/oauth
      readOnly: true
  volumes:
    - name: github-oauth
      secret:
        secretName: lunar-github-oauth
```

Then add `"client_secret_path":"/secrets/oauth/client-secret"` to the GitHub object in `HUB_AUTH_OAUTH_APPS`. The Hub refuses to start when that path is configured but missing or empty.

### Replicas and the Postgres connection budget

The Hub is stateless, so `replicaCount` scales it horizontally with no extra wiring — but each replica opens its own pool of Postgres connections, so total connections scale roughly linearly with the replica count:

```
total ≈ replicaCount × (hub.db.maxOpenConns + hub.db.maxPoolConns + hub.db.operatorPoolSize)
```

The chart defaults (`40 + 40 + 5 ≈ 85` per replica) match the Hub's built-in values, so a single replica is unchanged. Before running several replicas, confirm the total stays within your Postgres `max_connections` — leave headroom for the migrate Job, `psql`, and superuser-reserved slots. For larger replica counts, **lower `hub.db.maxOpenConns` / `hub.db.maxPoolConns`** so the total fits, and/or front Postgres with a connection pooler. Keep `maxPoolConns` at or above your peak concurrent queue workers (the sum of `hub.maxWorkers.*`) — the pool is shared by every queue, so a pool below that serializes store queries under load.

The chart soft-spreads replicas across nodes by default (`topologySpreadConstraints`, `maxSkew 1` across `kubernetes.io/hostname`, `ScheduleAnyway`) so a single node loss doesn't take out the whole Hub. Override `hub.topologySpreadConstraints` to impose your own (e.g. a hard zone spread) — it replaces the default wholesale.

### Personal SQL API credentials

Set `HUB_SQLAPI_USER_CREDENTIAL_TTL` through `hub.extraEnv` to control [personal credential](/sql-api/sql-api.md#personal-credentials) lifetime (default `720h`; `0` disables them). The Hub database role needs `CREATEROLE`; see [PostgreSQL prerequisites](/install/lunar-hub/self-hosted/prerequisites.md#step-3-provision-postgresql).

### A note on ingress

The Hub serves two protocols on separate ports — gRPC on `8000` (API) and plain HTTP on `8001` (webhook receiver + pre-signed URL redirector for run logs). Most ingress controllers apply `backend-protocol` per-Ingress rather than per-path, so the chart renders **two** Ingress resources sharing the same hostname and TLS config whenever `hub.ingress.enabled: true`. Per-protocol annotations layer over the shared `annotations` map: `grpcAnnotations` apply only to the gRPC Ingress, `httpAnnotations` only to the HTTP one. Both Ingresses are always created together — there's no per-protocol toggle.

If you'd rather front the Hub yourself (a controller with per-path backend protocols, a service mesh, or a LoadBalancer Service), set `hub.ingress.enabled: false` and route to the `hub-grpc` (port `8000`) and `hub-http` (port `8001`) service ports directly.

### Grafana

Lunar ships dashboards for policy results, component health, and collection activity, and installs them — with their datasources and panel plugins — into a Grafana over its HTTP API. There are three ways to provide that Grafana; pick the one that matches what you already run. The `grafana:` block in the values above shows the first (default) option.

In every case the chart auto-generates the read-only `grafana_user` datasource password (`<release>-grafana-db`) and wires it end-to-end for you. If you'd rather provide your own secret, set `grafana.provisioning.dbPassword.secretName` (recommended for GitOps).

#### Option A — bundled Grafana (default)

The default (`grafana.mode: chart`): the chart deploys a stock Grafana next to the Hub, runs a post-install/upgrade Job that installs the dashboards into it, and auto-generates the admin login (which the Hub uses to reach the pod).

Wire up the ingress in the `grafana:` block above so your team can reach it in a browser, and retrieve the admin login in [Step 6](#step-6-verify-the-install). Nothing else is required — `grafana.mode` defaults to `chart`.

#### Option B — Grafana Cloud or Grafana Enterprise (service-account token)

Point the Hub at your existing Grafana with a [service-account token](https://grafana.com/docs/grafana/latest/administration/service-accounts/). Create a service account with the **Admin** role, generate a token, then disable the bundled Grafana and wire the token in:

```yaml
grafana:
  mode: external                  # bring your own Grafana (no bundled pod)
  url: "https://your-org.grafana.net"
  auth:
    secretName: lunar-grafana     # holds the service-account token (created below)
    tokenKey: token
```

That's the whole configuration. On `helm install` / `helm upgrade` the chart's `post-install` / `post-upgrade` hook Job runs the provisioning tool against your Grafana — `deploy.sh` resolves the URL, credentials, and read-only datasource connection from the Hub over gRPC, then installs the plugins, datasources, and dashboards over Grafana's HTTP API. The Job is idempotent and non-blocking (a failed provision is logged but never rolls back the Hub upgrade), and it re-runs on every subsequent upgrade so the dashboards stay in sync with the Hub's schema. No manual step.

#### Option C — self-hosted OSS Grafana (admin username + password)

Same as Option B, but OSS Grafana can't install plugins with a service-account token — the dashboards' panel plugins need a **server admin**. Give the Hub an admin username and password instead of a token:

```yaml
grafana:
  mode: external
  url: "https://grafana.example.com"
  auth:
    secretName: lunar-grafana     # holds username + password (created below)
    # userKey / passwordKey default to "username" / "password"
```

Deploy the same way as Option B: `helm install` / `helm upgrade` runs the chart's hook Job automatically.

Turn on response compression on your Grafana (`GF_SERVER_ENABLE_GZIP=true`, or `enable_gzip = true` under `[server]` in `grafana.ini`). Grafana ships with it off, self-hosted Enterprise included; only Grafana Cloud compresses out of the box. The Catalogers dashboard downloads the whole catalog document through Grafana's query API — tens of megabytes on a large catalog, about 38x smaller gzipped. For the bundled Grafana (Option A), chart 4.10.0 and later set it for you; on an older chart add it under `grafana.extraEnv`. Only enable it on Grafana 13.0.8, 13.1.5, 13.2.1 or later: earlier 13.x releases leak a goroutine and its buffers on every `HEAD` request that accepts gzip, which is routine health-check traffic, until the process runs out of memory ([grafana/grafana#130893](https://github.com/grafana/grafana/pull/130893)). The chart's bundled Grafana is 13.1.6 since chart 4.5.0; on an older chart also set `grafana.image.tag: "13.1.6"`.

The examples reference a `lunar-grafana` secret you create — the key depends on your option: `token` for Option B, or `username` + `password` for Option C:

```bash
# Option B (Cloud / Enterprise)
kubectl -n lunar create secret generic lunar-grafana \
  --from-literal=token='<grafana-service-account-token>'

# Option C (OSS) — use these keys instead of `token`
kubectl -n lunar create secret generic lunar-grafana \
  --from-literal=username='admin' \
  --from-literal=password='<grafana-admin-password>'
```

{% hint style="info" %}
`grafana.url` + `grafana.auth` are all the chart needs for an external Grafana — it wires them into the Hub for you (as `HUB_GRAFANA_URL_BASE` and `HUB_GRAFANA_TOKEN`, or `HUB_GRAFANA_USER` / `HUB_GRAFANA_PASSWORD`), so there's no `hub.extraEnv` plumbing. The same Job that serves the bundled pod installs the dashboards here too, on every install and upgrade.
{% endhint %}

{% hint style="warning" %}
Options B and C need three network paths:

* **your Grafana → the Hub's Postgres** (as the read-only `grafana_user` role) — the Postgres datasource
* **your Grafana → the Hub's HTTP API** — the Infinity datasource
* **the in-cluster Job → your Grafana** (HTTP) — the deploy Job

Same-network self-hosted Grafana has all three. Grafana Cloud needs a route to both Hub endpoints (public endpoint, PrivateLink, or Private Data Source Connect) so the in-cluster Job can reach it.

If your cluster can't reach Grafana, [provision the dashboards from outside it](#provisioning-from-outside-the-cluster) instead. The first two paths are Grafana's own, and you need them either way.
{% endhint %}

#### Provisioning from outside the cluster

Lunar's dashboards are installed by a tool that calls Grafana's HTTP API. By default the chart runs it in-cluster as a Job, on every install and upgrade, so the connection to Grafana is opened from inside the cluster. Some networks don't allow that direction even when an operator can reach the same Grafana from a workstation or a bastion.

Set `grafana.mode: external-manual` and the chart renders no Job. You run the tool yourself instead, from wherever the connection is allowed:

```yaml
grafana:
  mode: external-manual
  url: "https://grafana.example.com"
  auth:
    secretName: lunar-grafana
    tokenKey: token              # omit for basic auth (auth.userKey + auth.passwordKey)
```

Nothing else changes: the read-only `grafana_user` role and its password are created as normal, and the Hub still vends the Grafana endpoint and database connection. Only the caller moves.

{% hint style="info" %}
**`grafana.mode: "off"` is not a substitute.** It skips the Job, but it also drops the read-only `grafana_user` role, its secret, and the Hub RPCs that vend the connection — leaving nothing for a manual run to use. `external-manual` removes the Job and keeps the rest.
{% endhint %}

After `helm install` or `helm upgrade`, run the tool. It only needs to reach your Grafana and the Hub; the Grafana credentials and the datasource connection come from the Hub over gRPC:

```bash
# ghcr.io/earthly/lunar-dashboards is private. The pull credential is carried in
# your licence JWT, so take it from there rather than a personal GHCR login:
lunar licence registry-token --licence-file=path/to/hub-licence.jwt \
  | docker login ghcr.io -u earthly-bot --password-stdin

export LUNAR_HUB_TOKEN=$(kubectl -n lunar get secret lunar-auth-token \
  -o jsonpath='{.data.token}' | base64 -d)

docker run --rm \
  -e LUNAR_HUB_HOST=hub.lunar.example.com \
  -e LUNAR_HUB_TOKEN \
  ghcr.io/earthly/lunar-dashboards:<hub-version>
```

Use the `lunar-dashboards` tag matching your `hub.image.tag`, so the dashboards match the Hub's schema. The chart prints this command filled in for your release, in the notes after `helm install`.

**Re-run it after every Lunar upgrade.** Nothing re-applies the dashboards for you here, and each version ships its own. Every step is idempotent, so it's safe to run at any time.

The Hub connection defaults to TLS on port 443, which is what a Hub behind an ingress needs. Don't copy `LUNAR_HUB_INSECURE=true` across from the chart's in-cluster Job — that one targets the plaintext Service.

To skip the Hub, set `GRAFANA_URL`, the Grafana credentials and the `POSTGRES_*` connection yourself; explicit values always win. `lunar sql connection-string --grafana` prints the database half. `LUNAR_HUB_HOST` is still required, because Grafana's Infinity datasource is configured with it.

<details>

<summary>Grafana with no <code>grafana.com</code> egress</summary>

Installing a panel plugin makes Grafana fetch it from `grafana.com`. Where Grafana has no route there, pre-install the three plugins it needs (`marcusolsson-dynamictext-panel`, `yesoreyeram-infinity-datasource`, `volkovlabs-echarts-panel`) through `GF_INSTALL_PLUGINS`, a vendored `.zip`, or an internal catalog mirror. Then tell the tool to stop trying to install them itself:

```bash
docker run --rm \
  -e LUNAR_HUB_HOST=hub.lunar.example.com \
  -e LUNAR_HUB_TOKEN \
  -e SKIP_PLUGINS=true \
  ghcr.io/earthly/lunar-dashboards:<hub-version>
```

{% hint style="warning" %}
**Pass `SKIP_PLUGINS` on the run itself, not as a chart value.** `grafana.provisioning.skipPlugins` sets the variable on the provisioning Job and the reconverge sidecar, and `external-manual` renders neither, so it reaches nothing in a run you launch. It does add `-e SKIP_PLUGINS=true` to the command the chart prints after install, which is the one place it still helps.
{% endhint %}

Without it the run stops at the plugin-permission preflight rather than pushing dashboards whose panels wouldn't render. Datasources and dashboards deploy normally once it's set, and a panel whose plugin is genuinely absent renders an error in place of the visualization.

The same variable covers the case where the credential can't install plugins at all: an org-scoped service-account token cannot hold the `plugins:install` permission, so a self-hosted OSS Grafana needs an admin username and password rather than a token.

</details>

#### Signing in through the Hub

The bundled Grafana has one admin login. To have people sign in with their Git platform account instead, make the Hub Grafana's identity provider with `grafana.sso`: Grafana's login page becomes a single **Sign in with Lunar** button, the Hub sends the person to GitHub or GitLab and lets them in if they hold [`hub.read`](/docs/authorization.md#hub-read), read access on your configuration repository. Everyone who gets in is a Grafana Viewer. A sign-in lasts at most seven days and renews on its own; signing out holds until the person presses the button again, and losing `hub.read` takes effect within 15 minutes.

It builds on [user logins](#user-logins), so `HUB_AUTH_SESSION_KEY_PATH` must be set, and the Hub needs an app to sign people in with. On GitHub that is the `lunar login` app, which now needs its [client secret and a redirect URI](/install/git-platforms/github.md#dashboard-sign-in). On GitLab create a [confidential application of the dashboards' own](/install/git-platforms/gitlab.md#dashboard-sign-in-application) and name it under `forgeApp`; sharing the `lunar login` application works too, at the price of GitLab's authorization page on every sign-in.

{% tabs %}
{% tab title="GitHub" %}

```yaml
hub:
  extraEnv:
    - name: HUB_AUTH_OAUTH_APPS
      value: '[{"forge":"github","host":"github.com","client_id":"Iv1.0123456789abcdef","client_secret_path":"/secrets/oauth/client-secret"}]'
grafana:
  sso:
    enabled: true
```

{% endtab %}

{% tab title="GitLab" %}

```bash
kubectl -n lunar create secret generic lunar-grafana-sso-forge \
  --from-file=client-secret=<path/to/application-secret>
```

```yaml
grafana:
  sso:
    enabled: true
    provider: gitlab/gitlab.com          # <forge>/<host> of the application below
    forgeApp:
      clientId: "<application-id>"
      secretName: lunar-grafana-sso-forge
```

{% endtab %}
{% endtabs %}

`grafana.sso.provider` is required whenever `HUB_AUTH_OAUTH_APPS` lists more than one app or `forgeApp` is set. The chart generates the credentials Grafana presents to the Hub (`<release>-grafana-sso`), configures Grafana, and serves `/oauth` on Grafana's address from the Grafana pod, so your ingress needs no new rule. Grafana's password form is off while sign-in is on; the provisioning Job keeps using the admin credentials over the API, and setting `grafana.sso.enabled: false` brings the form back. The chart refuses `grafana.sso` together with `grafana.anonymousViewer`.

If you reach Grafana by `kubectl port-forward`, note that the sign-in runs on `grafana.url` and ends there, so a tab opened on `localhost` never gets the session. `grafana.sso.loginForm: true` keeps the password form for whoever holds the admin secret; it shows only on `/login?disableAutoLogin`, which is the address to open through the port-forward, and auto-login still sends everyone else to the Git platform.

With **your own Grafana** (Option B or C) the chart configures the Hub's side only. Configure generic OAuth on your Grafana with client ID `lunar-grafana` and the secret from `<release>-grafana-sso`, PKCE and refresh tokens on, auto-login, scopes `openid profile email`, the `login`, `email`, `name`, and `role` attribute paths read from the same-named fields of the userinfo response, and these endpoints; `helm install` prints them in its notes:

| Grafana setting        | Value                           |
| ---------------------- | ------------------------------- |
| `auth_url`             | `<grafana.url>/oauth/authorize` |
| `token_url`            | `<grafana.url>/oauth/token`     |
| `api_url`              | `<grafana.url>/oauth/userinfo`  |
| `signout_redirect_url` | `<grafana.url>/oauth/signout`   |

Route `/oauth` on `grafana.url`'s host to the Hub's HTTP port (`8001`). The sign-in rides a cookie set on Grafana's address, so `/oauth` has to be served there and not on the Hub's API host.

#### Several Hubs sharing one Grafana

A dev Hub and a production Hub pointed at the same Grafana overwrite each other's dashboards and datasources, because both installs write the same fixed identifiers. Give each release its own `grafana.resourceSuffix` and they coexist:

```yaml
grafana:
  mode: external
  url: "https://grafana.example.com"
  resourceSuffix: "-prod"     # the dev release uses "-dev"
```

The suffix covers the folder, both datasources, and every dashboard UID, and the Hub's deep links follow it. Use letters, digits, `-` and `_`, up to 24 characters. Set it at first install; changing it later installs a fresh set and leaves the old dashboards behind.

### AWS credentials

The chart does not manage AWS credentials. The example above uses **IRSA on EKS** (annotation on the service account), which is the recommended pattern. The conceptual breakdown of patterns is in [prereqs Step 4 → Region and credentials](/install/lunar-hub/self-hosted/prerequisites.md#region-and-credentials).

### Pulling images from GHCR

Lunar Hub images are published to a private GitHub Container Registry (`ghcr.io/earthly/*`). To pull them your cluster needs an image pull secret. You can derive the pull secret with the [Lunar CLI](/install/cli.md):

```bash
lunar licence pull-secret \
  --licence-file=path/to/hub-licence.jwt \
  --namespace=lunar \
  --name=regcred | kubectl apply -f -
```

If your snippet pods run in a separate namespace (`operator.snippetNamespace`), run it again for that namespace:

```bash
lunar licence pull-secret \
  --licence-file=path/to/hub-licence.jwt \
  --namespace=lunar-scripts \
  --name=regcred | kubectl apply -f -
```

Reference the secret in your `values.yaml`:

```yaml
imagePullSecrets:
  - name: regcred

hub:
  image:
    repository: ghcr.io/earthly/lunar-hub

operator:
  image:
    repository: ghcr.io/earthly/lunar-snippet-operator
  initImage:
    repository: ghcr.io/earthly/lunar-snippet-init
  sidecarImage:
    repository: ghcr.io/earthly/lunar-snippet-sidecar
```

Lunar also ships the Grafana dashboards in a private deploy image, `ghcr.io/earthly/lunar-dashboards`, which the chart's dashboards-deploy Job pulls under the same `imagePullSecrets` — it uses that repository by default, so no extra image configuration is needed.

## Step 5 — Install

```bash
helm install lunar earthly/lunar \
  --namespace lunar \
  -f values.yaml
```

The chart fails fast with a clear error if any of the [required fields](https://github.com/earthly/charts/blob/main/README.md#required-values) are missing.

Watch the pods come up:

```bash
kubectl -n lunar get pods -w
```

Expected rollout, in order:

1. `lunar-hub-migrate-<hash>` runs first — a pre-install Helm hook Job that applies DB migrations and gates the rest of the rollout. It runs to `Completed` before any Hub pod is created.
2. `lunar-hub-<hash>` pods start (one per `replicaCount`). Each asserts the schema is current at boot and refuses to start if it's behind — so serving pods never run against an un-migrated schema. They flip to `Running 1/1` once the readiness probe (`/ready`) passes.
3. `lunar-operator-<hash>` depends on the applied migrations. Once the Hub is up, it flips to `Running 1/1` shortly after.

Migrations can take a minute or two on a warm DB. If the migrate Job fails, or a Hub pod restarts repeatedly, check the logs:

```bash
# Migrate Job (if it failed the hook, the whole install/upgrade is rolled back):
kubectl -n lunar logs -l app.kubernetes.io/component=hub-migrate --tail=200
# Hub pods:
kubectl -n lunar logs -l app.kubernetes.io/component=hub --tail=200
```

Common first-install failures (visible while the Hub is starting up):

| Symptom in logs                             | Fix                                                                                                                                                                                                                                                                                                                                                   |
| ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `pq: permission denied for schema public`   | The DB role lacks `CREATE` on the database. Grant it, or use a database-owner role. See [prerequisites step 3](/install/lunar-hub/self-hosted/prerequisites.md#step-3-provision-postgresql).                                                                                                                                                          |
| `dial tcp ...: i/o timeout`                 | The Hub pod can't reach Postgres on the network — security group, network policy, or firewall block between the pod and the DB. Check VPC routing and any `NetworkPolicy` in the `lunar` namespace.                                                                                                                                                   |
| `dial tcp ...: connect: connection refused` | Postgres is reachable but not listening on the configured port (`hub.db.port`, default `5432`), or the process is down.                                                                                                                                                                                                                               |
| `lookup ...: no such host`                  | DNS issue — the DB hostname doesn't resolve from inside the cluster.                                                                                                                                                                                                                                                                                  |
| `failed to parse private key`               | The `lunar-github-app` secret doesn't contain a valid PEM, or the PEM wasn't base64-encoded before going into the secret value (see the base64 note in [Step 3](#step-3-create-kubernetes-secrets)). Recreate from the PEM you saved during [prerequisites step 5](/install/lunar-hub/self-hosted/prerequisites.md#step-5-connect-your-git-platform). |
| `no forge configured`                       | Neither GitHub App credentials nor `HUB_GITLAB_TOKENS` are set. At least one Git platform is required. See [prerequisites step 5](/install/lunar-hub/self-hosted/prerequisites.md#step-5-connect-your-git-platform).                                                                                                                                  |
| `read HUB_GITLAB_TOKENS ... token_path`     | The GitLab token file isn't where the config says it is. Confirm `token_path` matches the `mountPath` of the volume you mounted the `lunar-gitlab-token` secret at.                                                                                                                                                                                   |

The Hub validates Postgres at startup, but **defers GitHub and S3 to first use** — those misconfigurations won't appear in install logs. They surface once something exercises them (e.g. `lunar hub pull github://...` or a script run):

| Symptom (after triggering an operation)     | Likely cause                                                                                                                                                                                                  |
| ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `401 Unauthorized` from GitHub API          | Wrong `hub.github.app.id`, or the PEM in the `lunar-github-app` secret doesn't match the App GitHub knows about.                                                                                              |
| `404 Not Found` on `installations/<id>/...` | Wrong `hub.github.app.installId`, or the App is no longer installed on the org.                                                                                                                               |
| `AccessDenied` from S3                      | IAM policy too narrow — confirm `s3:GetObject` and `s3:PutObject` on both buckets. See [prerequisites step 4](/install/lunar-hub/self-hosted/prerequisites.md#step-4-provision-s3-compatible-object-storage). |
| `NoSuchBucket` from S3                      | Bucket name mismatch between `hub.s3.logsBucket` / `hub.s3.resourcesBucket` and what actually exists.                                                                                                         |
| `PermanentRedirect` from S3                 | `AWS_REGION` doesn't match the bucket's region.                                                                                                                                                               |

## Step 6 — Verify the install

Once the Hub and Operator pods are `Running 1/1`, confirm the Hub is healthy end to end.

**Retrieve the Hub service token** — the chart generated this on first install. You'll need it for the CLI step below.

```bash
kubectl -n lunar get secret lunar-auth-token \
  -o jsonpath='{.data.token}' | base64 -d
```

**Grafana access** — this depends on which [Grafana option](#grafana) you chose.

If you use the **bundled Grafana** (Option A), retrieve its auto-generated admin credentials:

```bash
kubectl -n lunar get secret lunar-grafana-admin \
  -o jsonpath='{.data.username}' | base64 -d
  
kubectl -n lunar get secret lunar-grafana-admin \
  -o jsonpath='{.data.password}' | base64 -d
```

The commands above assume the release name `lunar` (from `helm install lunar ...`). For other release names, see [Required secrets](https://github.com/earthly/charts/blob/main/README.md#required-secrets) in the chart README for the resolved name.

Open `https://grafana.lunar.example.com` and log in with the credentials above; with [sign-in through the Hub](#signing-in-through-the-hub) on, press **Sign in with Lunar** instead and sign in with your Git platform account. The home dashboard surfaces policy results, component health, and collection activity. If the page doesn't load, confirm the `grafana.lunar.example.com` `A` / `CNAME` record points at your ingress controller and the cert is valid.

If you pointed Lunar at **your own Grafana** (Option B or C), log in there with your existing credentials — the Lunar dashboards appear in a "Lunar" folder.

**HTTP ingress reachability** — the webhook endpoint rejects unsigned requests but returns a predictable status, so it's a decent routing smoke test:

{% tabs %}
{% tab title="GitHub" %}

```bash
curl -sS -o /dev/null -w "%{http_code}\n" https://lunar.example.com/webhooks/github
```

{% endtab %}

{% tab title="GitLab" %}

```bash
curl -sS -o /dev/null -w "%{http_code}\n" https://lunar.example.com/webhooks/gitlab
```

{% endtab %}
{% endtabs %}

Expected: `405`. The endpoints accept only `POST`, and the method check runs before any signature validation, so a plain `GET` like this is rejected with `405 Method Not Allowed`. That still proves the route reaches the Hub. `404` or `502` probably means the HTTP Ingress isn't reaching the Hub, so confirm `hub.ingress.enabled: true` in your values, and that DNS still resolves to your ingress controller.

**CLI connectivity smoke test** — install the [Lunar CLI](/install/cli.md), then save this minimal [`lunar-config.yml`](/configuration/lunar-config.md) locally and run:

```yaml
version: 0
hub:
  host: lunar.example.com
  grpcPort: 443
  httpPort: 443
collectors: []
policies: []
```

```bash
export LUNAR_HUB_TOKEN=<token-from-step-above>
lunar secret list
```

This authenticates over gRPC and returns the configured script runtime secrets (an empty list on a fresh install). Success means the CLI can reach the Hub through ingress, TLS terminates correctly, and the auth token is valid. Adjust the ports if you're using NodePort or non-default ingress.

**End-to-end with a config repo** — if you have a [`lunar-config.yml`](/configuration/lunar-config.md) repository ready, pull it in. This exercises the full path: CLI → Hub → your Git platform → repo fetch → Hub stores config. As a side effect, the Hub registers a webhook on every repository referenced in the config.

{% tabs %}
{% tab title="GitHub" %}

```bash
lunar hub pull github://your-org/your-config-repo@main
```

Verify the webhook on any repository in the config: repo → **Settings → Webhooks**, pointing at `https://lunar.example.com/webhooks/github`.
{% endtab %}

{% tab title="GitLab" %}

```bash
lunar hub pull gitlab://gitlab.com/your-group/your-config-repo@main
```

The host is required in the URL, even for gitlab.com.

Verify the webhook on any project in the config: project → **Settings → Webhooks**, pointing at `https://lunar.example.com/webhooks/gitlab`.
{% endtab %}
{% endtabs %}

If you don't have a config repo yet, that's the natural next step — see [the lunar-config docs](/configuration/lunar-config.md) for setup.

## Script runtime secrets

Collectors or catalogers that hit external APIs (`github.*` collectors, Datadog, Jira, Linear, etc.) may need credentials at runtime. Each plugin's README in [`earthly/lunar-lib`](https://github.com/earthly/lunar-lib) lists the `LUNAR_SECRET_*` variables it expects — drop the prefix when writing the secret. The Hub re-adds `LUNAR_SECRET_` when it injects each key as an env var in the script pod, so `GH_TOKEN` in the secret surfaces as `$LUNAR_SECRET_GH_TOKEN` in the script.

Provision the secret with one `--from-literal` per variable:

```bash
kubectl -n lunar create secret generic lunar-script-secrets \
  --from-literal=GH_TOKEN='<your-pat>' \
  --from-literal=LINEAR_API_KEY='<your-linear-key>'
  # ... one --from-literal per LUNAR_SECRET_* variable your plugins expect
```

Wire it into `values.yaml` (`hub.secrets.*.perKey` requires chart `2.2.0+`). The same secret can back both scopes; omit the one you don't use:

```yaml
hub:
  secrets:
    collector:
      secretName: lunar-script-secrets
      perKey: true
    cataloger:
      secretName: lunar-script-secrets
      perKey: true
```

See [day-2 → Other secrets](/install/lunar-hub/self-hosted/day-2-operations.md#other-secrets) for rotation.

## Next steps

* [Install the Lunar CLI](/install/cli.md) on your workstation.
* [Install the Lunar CI Tracer](/install/ci-tracer/github-actions-self-hosted.md) on your CI runners.
* [Day-2 operations](/install/lunar-hub/self-hosted/day-2-operations.md) — upgrades, secret rotation, observability, uninstall.


---

# 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/lunar-hub/self-hosted/install-walkthrough.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.
