Install Walkthrough
This walkthrough takes you from zero to a working Lunar Hub. It assumes you've worked through the prerequisites, 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
helm repo add earthly https://earthly.github.io/charts
helm repo updateVerify:
helm search repo earthly/lunarYou 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. An issue here is cheaper to find now than after you've created secrets and written your values.yaml.
DNS (prereqs Step 2):
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):
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):
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
A signed Hub licence JWT (from your Earthly contact)
Other secrets, like the Hub auth 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.
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:
Git platform credential — create the one that matches your platform, or both if you run both:
Base64-encode the App's PEM and store it as the private-key field:
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.)
The Hub reads the group access token from a mounted file, so it goes in as plain text with no base64 step:
Step 4 mounts this secret at the token_path you declare in HUB_GITLAB_TOKENS.
Hub licence JWT — store the signed token in hub-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 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 below covers what to change on GitLab.
Everything else has a sensible default. See the chart README 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.
The hub.github.app block in the sample above is the whole configuration. Its id, installId, and owner come from prereqs Step 5; 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:
Fronting several GitHub orgs from one Hub takes a different shape. See GitHub → multiple organizations.
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.
One entry per top-level group; token_path must match the mountPath above. The webhook signing secret is not in there: the chart generates it, the same as it does for GitHub. GitLab → Hub configuration has the full field reference and the multi-group form.
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.
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:
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.
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. 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. Create a service account with the Admin role, generate a token, then disable the bundled Grafana and wire the token in:
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:
Deploy the same way as Option B: helm install / helm upgrade runs the chart's hook Job automatically.
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:
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 chart's provisioning Job runs whenever grafana.mode != off — the same Job that serves the bundled pod — installing the dashboards on every install/upgrade.
Options B and C need three network paths:
your Grafana → the Hub's Postgres (as the read-only
grafana_userrole) — the Postgres datasourceyour 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.
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.
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:
If your snippet pods run in a separate namespace (operator.snippetNamespace), run it again for that namespace:
Reference the secret in your values.yaml:
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
The chart fails fast with a clear error if any of the required fields are missing.
Watch the pods come up:
Expected rollout, in order:
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 toCompletedbefore any Hub pod is created.lunar-hub-<hash>pods start (one perreplicaCount). 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 toRunning 1/1once the readiness probe (/ready) passes.lunar-operator-<hash>depends on the applied migrations. Once the Hub is up, it flips toRunning 1/1shortly 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:
Common first-install failures (visible while the Hub is starting up):
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.
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). Recreate from the PEM you saved during prerequisites step 5.
no forge configured
Neither GitHub App credentials nor HUB_GITLAB_TOKENS are set. At least one Git platform is required. See prerequisites step 5.
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):
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.
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 auth token — the chart generated this on first install. You'll need it for the CLI step below.
Grafana access — this depends on which Grafana option you chose.
If you use the bundled Grafana (Option A), retrieve its auto-generated admin credentials:
The commands above assume the release name lunar (from helm install lunar ...). For other release names, see Required secrets in the chart README for the resolved name.
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.
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:
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, then save this minimal lunar-config.yml locally and run:
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 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.
Verify the webhook on any repository in the config: repo → Settings → Webhooks, pointing at https://lunar.example.com/webhooks/github.
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.
If you don't have a config repo yet, that's the natural next step — see the lunar-config docs 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 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:
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:
See day-2 → Other secrets for rotation.
Next steps
Install the Lunar CLI on your workstation.
Install the Lunar CI Tracer on your CI runners.
Day-2 operations — upgrades, secret rotation, observability, uninstall.
Last updated
