> 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/hub-install.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/hub-prereqs.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/hub-prereqs.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/hub-prereqs.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/hub-prereqs.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/hub-prereqs.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
* The GitHub App private key (the PEM you saved during [prereqs Step 5](/install/lunar-hub/hub-prereqs.md#step-5--create-a-github-app))
* A signed Hub licence JWT (from your Earthly contact)

Other secrets, like the Hub auth token, GitHub webhook secret, and 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>'
```

**GitHub App private key** — base64-encode the 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 %}

**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. The GitHub App ID and installation ID come from [prereqs Step 5](/install/lunar-hub/hub-prereqs.md#step-5--create-a-github-app).

```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"

  # 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: "2.4.1"

  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: "2.4.1"
  initImage:
    tag: "2.4.1"
  sidecarImage:
    tag: "2.4.1"

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

# Grafana is the primary UI for Lunar — dashboards for policy results,
# component health, and collection activity. It's enabled by default in
# the chart; this block just wires up its ingress. Set `grafana.enabled:
# false` if you have a specific reason to disable it.
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.

### 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.

### 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/hub-prereqs.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

grafana:
  image:
    repository: ghcr.io/earthly/lunar-grafana
```

## 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-<hash>` starts as `Running 0/1` while the container runs DB migrations, then flips to `Running 1/1` once the readiness probe passes.
2. `lunar-operator-<hash>` depends on some of the migrations applied by `lunar-hub-<hash>`. Once those have applied, it flips to `Running 1/1` shortly after.

First-boot migrations can take a minute or two on a warm DB. If the Hub pod restarts repeatedly, check the logs:

```bash
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/hub-prereqs.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/hub-prereqs.md#step-5--create-a-github-app). |

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/hub-prereqs.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 both pods are `Running 1/1`, confirm the Hub is healthy end to end.

**Retrieve the Hub auth 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
```

Retrieve the Grafana admin credentials — you'll need them to log in below:

```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.

**Grafana access** — open `https://grafana.lunar.example.com` and log in with the credentials above. 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.

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

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

Expected: `400` (the Hub rejects the request for missing headers). `404` or `502` probably means the HTTP Ingress isn't reaching the Hub — 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:

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

This exercises the full path: CLI → Hub → GitHub (App auth) → repo fetch → Hub stores config. As a side effect, the Hub registers a webhook on every repo referenced in the config. Verify webhook registration by checking any of those repos → **Settings → Webhooks** in GitHub — you should see a new entry pointing at `https://lunar.example.com/webhooks/github`.

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/hub-day2.md#other-secrets) for rotation.

## Next steps

* [Install the Lunar CLI](/install/cli.md) on your workstation.
* [Install the Lunar CI Agent](/install/lunar-ci-agent/agent-self-hosted.md) on your CI runners.
* [Day-2 operations](/install/lunar-hub/hub-day2.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 dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://docs-lunar.earthly.dev/install/lunar-hub/hub-install.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

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.
