Helm charts for deploying Earthly Lunar on Kubernetes.
- Kubernetes 1.29+
- Helm 3.x
- PostgreSQL 16+ (external — not included in the chart)
- S3-compatible object storage (two buckets: logs and resources)
- A GitHub App installed on your organization — the fastest path to create one is our manifest script (browser-based flow, prints the credentials you'll need below)
helm repo add earthly https://earthly.github.io/charts
helm repo update
helm install lunar earthly/lunar \
--namespace lunar --create-namespace \
-f values.yamlBefore the command above will succeed, create the required secrets and set the required values.
The chart requires three user-managed secrets up front: the database credentials, the GitHub App private key, and the Hub licence JWT. Three other secrets (the hub auth token, the GitHub webhook secret, and — when enabled — the Grafana admin credentials) are auto-generated by the chart on install with a random value, persisted as Kubernetes Secrets with helm.sh/resource-policy: keep, and re-read from the cluster on subsequent upgrades. To bring your own instead of the auto-generated value, set the secretName field on the corresponding values key — the chart will reference your secret and skip auto-generation. See the GitOps note below if you use ArgoCD or Flux.
# Database credentials (always user-managed)
kubectl -n lunar create secret generic lunar-db \
--from-literal=username='<db-user>' \
--from-literal=password='<db-password>'
# GitHub App private key (always user-managed).
# The hub base64-decodes this value internally, so the secret must contain the
# base64-encoded PEM (not the raw PEM). The one-liner below works on both
# GNU and BSD/macOS base64.
kubectl -n lunar create secret generic lunar-github-app \
--from-literal=private-key="$(base64 < path/to/private-key.pem | tr -d '\n')"
# Hub licence JWT (always user-managed).
kubectl -n lunar create secret generic lunar-hub-licence \
--from-literal=hub-licence.jwt='<signed-licence-jwt>'After install, retrieve any chart-managed secret with:
# Hub auth token (pass to CLI / CI agents as LUNAR_HUB_TOKEN)
kubectl -n lunar get secret lunar-auth-token \
-o jsonpath='{.data.token}' | base64 -d
# GitHub webhook secret (register with your GitHub App)
kubectl -n lunar get secret lunar-github-webhook \
-o jsonpath='{.data.webhook-secret}' | base64 -d(Adjust lunar-... for your release name; the actual names are <release>-auth-token, <release>-github-webhook, <release>-grafana-admin.)
Helm's lookup function — used by the chart to persist auto-generated secrets across upgrades — returns empty during client-side template rendering. ArgoCD and Flux render Helm charts client-side by default, which means without a workaround they would regenerate the random values on every reconciliation and detect continuous drift.
Two options if you use GitOps:
-
Bring-your-own-secrets — set
hub.auth.secretName,hub.github.webhookSecret.secretName, andgrafana.auth.secretNameto secrets you manage with External Secrets Operator, Sealed Secrets, Vault, etc. The chart will reference them and skip auto-generation entirely. -
Tell your GitOps tool to ignore the generated fields — example for ArgoCD:
ignoreDifferences: - kind: Secret jsonPointers: - /data/token - /data/webhook-secret - /data/password
Minimum values.yaml that has to be provided — everything else has a sensible default.
hub:
licence:
secretName: "lunar-hub-licence"
secretKey: "hub-licence.jwt"
db:
name: lunar
host: your-db-host.example.com
s3:
logsBucket: your-lunar-logs-bucket
resourcesBucket: your-lunar-resources-bucket
github:
app:
id: 123456
installId: 78901234Plus two URL prerequisites the chart needs to wire correctly:
-
Hub webhook URL — for GitHub webhook registration to work, the hub needs an externally-reachable URL. The simplest path is to let the chart manage your ingress and set
hub.ingress.webhooks.host— see Ingress below. Whenhub.ingress.enabled: true, the chart deriveshttps://<webhooks.host>automatically. BYO-ingress installs (hub.ingress.enabled: false) must sethub.webhookURLexplicitly — the chart will not guess a URL it doesn't route to. -
Grafana URL (when
grafana.mode: chart, the default) — Grafana needs to know its own external URL for OIDCredirect_uriand absolute link rendering. Pick one:grafana.ingress.enabled: truewithgrafana.ingress.hosts[0].hostset — chart derives the URL automatically.grafana.url: "https://grafana.example.com"— explicit override, for BYO ingress / Caddy / shared LB with path routing.grafana.mode: off— skip Grafana entirely (orgrafana.mode: externalto install the dashboards into a bring-your-own Grafana).
The chart fails fast at install time if none of these are set (it deliberately won't guess at a URL it doesn't route to — wrong host means broken OIDC, silently).
Hub authenticates to GitHub as a GitHub App. Two modes, mutually exclusive:
-
Single-App (default). Set
hub.github.app.owner(the GitHub org or user the App is installed on),hub.github.app.id, andhub.github.app.installId, then create thelunar-github-appSecret holding the App's private-key PEM. Suitable for single-tenant deployments where the Hub fronts one App installed in one org. Required as of 2.2.0:hub.github.app.owneris now required — prior chart versions inferred a default routing internally; the Hub now requires the value explicitly viaHUB_GITHUB_APP_OWNER. -
Multi-App. Use this when the Hub serves multiple orgs that each install their own Lunar App. List one entry per owner under
hub.github.apps, and put all the PEM files in a single Kubernetes Secret named viahub.github.appsSecret.secretName. The chart looks up each entry's PEM at<lowercase-owner>.peminside that Secret.An owner may appear more than once, with Hub 3.14.0 or newer — earlier Hubs reject a repeated owner at boot. GitHub's REST rate limit applies per App installation, so a second App installed on the same org gives the Hub a second, independent budget; give each entry its own
privateKeyFileso they don't derive the same PEM name. The Hub spreads read traffic evenly across an owner's Apps, while commit statuses and PR comments always use the first entry — GitHub only lets the App that created a check run or comment update it.hub: github: apps: - owner: earthly appId: 123 installId: 100 # A GitHub Enterprise Server org — set host + baseUrl (see below). - owner: acme-ghes host: ghes.acme.com baseUrl: https://ghes.acme.com/api/v3 appId: 456 installId: 200 appsSecret: secretName: lunar-github-apps
Each entry may also carry an optional
hostandbaseUrl. They default togithub.comand the github.com API, so plain github.com / GitHub Enterprise Cloud entries omit them. For a GitHub Enterprise Server org set both —host(e.g.ghes.acme.com) so the Hub keys that org's components by(host, owner, name)and routes its webhooks correctly, andbaseUrl(e.g.https://ghes.acme.com/api/v3) so the Hub calls that instance's API. One Hub can serve github.com and GHES orgs side by side this way. The GHES box must be reachable from the hub pod and its CA trusted by it.Create the Secret out of band:
kubectl create secret generic lunar-github-apps \ --from-file=earthly.pem=./earthly.pem \ --from-file=acme.pem=./acme.pem
The chart validates the chosen mode at install time with helm.sh/fail (mutex, required fields, duplicate owners).
Hub authenticates to GitLab with group access tokens — one per top-level group it should serve (a top-level token also covers that group's subgroups). GitLab may be configured alongside GitHub (mixed-forge) or alone; at least one forge is required — a GitLab-only install renders with no hub.github block at all.
List one entry per group under hub.gitlab.tokens, and put all the tokens in a single Kubernetes Secret named via hub.gitlab.tokensSecret.secretName. The chart looks up each entry's token at <lowercase-group>.token inside that Secret:
hub:
gitlab:
tokens:
- group: acme # gitlab.com group
# A self-managed or GitLab Dedicated instance — host is REQUIRED for
# non-gitlab.com hosts, or the Hub treats them as GitHub.
- group: platform
host: gitlab.example.com
tokensSecret:
secretName: lunar-gitlab-tokenCreate the Secret out of band (group is the URL path segment, not the display name):
kubectl create secret generic lunar-gitlab-token \
--from-file=acme.token=./acme.token \
--from-file=platform.token=./platform.tokenWebhooks need no manual step. The Hub registers project hooks itself and signs them with the secret behind hub.gitlab.webhookSecret (chart-generated as <release>-gitlab-webhook when secretName is empty; BYO for GitOps — same pattern and caveats as hub.github.webhookSecret, minus the paste-back: there is nothing to configure on the GitLab side). Requires a hub image with HUB_GITLAB_WEBHOOK_SECRET support.
The chart validates GitLab config at install time (required group, valid top-level path, duplicate groups, tokens Secret name).
Lunar uses two S3-compatible buckets — one for streaming script logs, one for script resource archives fetched by init containers. Both must exist and be writable before pods start doing real work.
AWS credentials are intentionally out of scope for this chart. The hub uses the standard AWS SDK credential chain, so you can use whichever mechanism fits your cluster:
-
IRSA (recommended on EKS) — annotate the chart's service account with the role ARN. The role needs
s3:GetObjectands3:PutObjecton both buckets.serviceAccount: annotations: eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/lunar-hub-s3 hub: extraEnv: - name: AWS_REGION value: us-east-1
-
Explicit env vars — inject credentials via
hub.extraEnv, sourced from an existing secret:hub: extraEnv: - name: AWS_REGION value: us-east-1 - name: AWS_ACCESS_KEY_ID valueFrom: secretKeyRef: { name: lunar-aws, key: access-key-id } - name: AWS_SECRET_ACCESS_KEY valueFrom: secretKeyRef: { name: lunar-aws, key: secret-access-key }
Other providers (GKE Workload Identity, pod identity, IMDS, etc.) all work the same way — set whatever AWS_* or service-account plumbing you'd normally use for any AWS-SDK workload. The chart does not create the buckets for you.
Only create these if you need the features they enable.
# Per-scope runtime secrets (only when the matching hub.secrets.*.secretName is set).
# The k8s secret's `secrets` data key is parsed by Hub as a comma-separated list
# of `NAME:VALUE` pairs (kelseyhightower/envconfig map format — NOT JSON). Each
# pair surfaces in script pods as `LUNAR_SECRET_<NAME>`.
# Most installs don't need these — prefer per-type script container spec envFrom /
# volumes (see operator.scriptContainerSpec*) for fine-grained control.
kubectl -n lunar create secret generic lunar-collector-secrets --from-literal=secrets='GH_TOKEN:ghp_xxx,NPM_TOKEN:npm_yyy'
kubectl -n lunar create secret generic lunar-cataloger-secrets --from-literal=secrets='GH_TOKEN:ghp_xxx'
kubectl -n lunar create secret generic lunar-policy-secrets --from-literal=secrets='SLACK_WEBHOOK_URL:https://hooks.slack.com/services/...'
# Grafana admin (only if you set grafana.auth.secretName instead of letting
# the chart auto-generate; both username and password live in one secret)
kubectl -n lunar create secret generic my-grafana-admin \
--from-literal=username='admin' \
--from-literal=password='<your-grafana-password>'The hub has two trust boundaries:
- Trusted API clients — lunar CLI, CI agents, operator. Talk to hub over gRPC (port 8000) and HTTP (
/logson port 8001). Both use the same Hub auth token. - GitHub webhooks — public internet. POST to
/webhookson port 8001 only.
The chart renders separate ingresses for each, configured under two logical blocks: hub.ingress.api and hub.ingress.webhooks. You can put the API ingress on a private hostname (Tailscale, internal-DNS, VPN) and only expose webhooks to the public internet — or use the same hostname for both. Disable entirely (hub.ingress.enabled: false) if you front the hub with a LoadBalancer Service, a service mesh, or your own Ingress YAML.
Single-host install (same hostname serves both — fine for most setups):
hub:
ingress:
enabled: true
className: nginx
tls:
- secretName: lunar-tls
hosts:
- lunar.example.com
annotations:
cert-manager.io/cluster-issuer: letsencrypt
api:
host: lunar.example.com
# NGINX needs backend-protocol: GRPC on the gRPC Ingress. Other
# controllers use their own equivalent (ALB: backend-protocol-version:
# GRPC; Traefik: h2c per-service; etc).
grpcAnnotations:
nginx.ingress.kubernetes.io/backend-protocol: "GRPC"
webhooks:
host: lunar.example.comSplit-host install (API stays internal, webhooks ingress is the only public surface):
hub:
ingress:
enabled: true
className: nginx
annotations:
cert-manager.io/cluster-issuer: letsencrypt
api:
host: api.lunar.example.com
tls:
- secretName: lunar-api-tls
hosts:
- api.lunar.example.com
# Per-sub-Ingress annotations. Repeat the whitelist on both so it
# applies to api-grpc AND api-http; the chart intentionally does not
# apply it to webhooks (different trust boundary).
grpcAnnotations:
nginx.ingress.kubernetes.io/backend-protocol: "GRPC"
nginx.ingress.kubernetes.io/whitelist-source-range: "10.0.0.0/8"
httpAnnotations:
nginx.ingress.kubernetes.io/whitelist-source-range: "10.0.0.0/8"
webhooks:
host: webhooks.lunar.example.com
tls:
- secretName: lunar-webhooks-tls
hosts:
- webhooks.lunar.example.comWhen the hub boots, it reads hub.webhookURL (derived as https://<hub.ingress.webhooks.host> when hub.ingress.enabled: true; must be set explicitly otherwise) and registers <webhookURL>/webhooks/github with the GitHub App. GitHub then POSTs every webhook event to that URL, which must resolve to the webhooks ingress.
sequenceDiagram
participant Hub as Hub<br/>(reads webhookURL)
participant GH as GitHub App
participant DNS
participant Ingress as Webhooks Ingress<br/>(listens on webhooks.host)
participant HubPod as Hub Pod<br/>:8001
Hub->>GH: "Register webhook at <webhookURL>/webhooks/github"
Note over Hub,GH: One-time, at hub boot
Note over GH: Later: a PR event happens
GH->>DNS: Resolve <webhookURL host>
DNS-->>GH: Cluster LB IP
GH->>Ingress: POST /webhooks/github
Ingress->>HubPod: Forward
HubPod-->>Ingress: 200 OK
Ingress-->>GH: 200 OK
Three things must agree, or webhooks land in the void:
- The hostname inside
hub.webhookURL(derived fromwebhooks.hostwhenhub.ingress.enabled: true; set explicitly otherwise). - The DNS record for that hostname must resolve to your cluster's ingress.
- An Ingress with a rule for that hostname and
/webhookspath — which the chart creates for you whenhub.ingress.enabled: true; BYO installs are responsible for routing themselves.
The chart enforces #1 internally: if you set hub.webhookURL explicitly alongside chart-managed ingress, its host portion must equal hub.ingress.webhooks.host. Install fails fast if they disagree.
Three Ingress resources, all in the release namespace:
| Resource | Host | Path | Backend port |
|---|---|---|---|
<release>-hub-api-grpc |
api.host |
/ |
hub gRPC (8000) |
<release>-hub-api-http |
api.host |
/logs |
hub HTTP (8001) |
<release>-hub-webhooks |
webhooks.host |
/webhooks |
hub HTTP (8001) |
The api-grpc and api-http split exists because most ingress controllers apply backend-protocol annotations per-Ingress.
The ingress shape changed in chart 2.0.0:
hub.publicBaseURLwas renamed tohub.webhookURL, and is now optional whenhub.ingress.enabled: true— defaults tohttps://<webhooks.host>in that case. BYO-ingress installs must set it explicitly (the chart will not derive a URL it doesn't route to). Also set explicitly for non-default scheme/port or a path prefix.hub.ingress.hostmoved tohub.ingress.api.hostandhub.ingress.webhooks.host(set both to the same value for a single-host install).hub.ingress.grpcAnnotations/httpAnnotationsmoved underhub.ingress.api.*.hub.ingress.api.grpcAnnotationsno longer defaults to NGINX'sbackend-protocol: "GRPC". NGINX users must set it explicitly (see Ingress examples); other controllers set their equivalent.hub.grafanaURLBasewas renamed (grafana.externalURLin 2.0.0, nowgrafana.urlin 3.0.0). It's a Grafana property — Hub consumes it asHUB_GRAFANA_URL_BASE, Grafana consumes it asGF_SERVER_ROOT_URL. Lives undergrafana.*now, where it belongs.- Grafana URL derivation changed. In 1.x,
HUB_GRAFANA_URL_BASEand Grafana's ownGF_SERVER_ROOT_URLdefaulted topublicBaseURL. 2.0.0 only derives a Grafana URL when the chart actually controls Grafana's routing (chart-managedgrafana.ingressor explicitgrafana.url). If you used a single hostname withgrafana.ingress.enabled: falseand external path-routing (Caddy / nginx-ingress with split paths / similar), setgrafana.urlto that same URL explicitly — otherwise Grafana's OIDCredirect_uriand absolute links break after upgrade. The chart fails fast at install time whengrafana.mode: chartand no Grafana URL can be determined.
The chart fails fast at install time when it sees the old shape.
# Before (chart 1.x)
hub:
publicBaseURL: "https://lunar.example.com"
ingress:
enabled: true
host: lunar.example.com
grpcAnnotations:
nginx.ingress.kubernetes.io/backend-protocol: "GRPC"
# After (chart 2.0.0) — single-host
hub:
# webhookURL derived as "https://lunar.example.com" automatically.
ingress:
enabled: true
api:
host: lunar.example.com
grpcAnnotations:
nginx.ingress.kubernetes.io/backend-protocol: "GRPC"
webhooks:
host: lunar.example.com
# After (chart 2.0.0) — split-host
hub:
# webhookURL derived as "https://webhooks.lunar.example.com".
ingress:
enabled: true
api:
host: api.lunar.example.com
grpcAnnotations:
nginx.ingress.kubernetes.io/backend-protocol: "GRPC"
webhooks:
host: webhooks.lunar.example.com
# After (chart 2.0.0) — BYO ingress (LoadBalancer / mesh / your own YAML).
# The chart wires HUB_PUBLIC_BASE_URL, HUB_GRAFANA_URL_BASE, and Grafana's
# GF_SERVER_ROOT_URL from the values below — set whichever apply. You own
# routing GitHub's POSTs to the hub-http Service (named `<release>-hub:8001`)
# and routing browser traffic to the grafana Service. The chart only emits
# these env vars when the corresponding value is set; unset means the
# component falls back to its own default (or warns at boot).
hub:
webhookURL: "https://lunar.example.com"
ingress:
enabled: false
grafana:
# Required when grafana.mode is chart and the chart isn't managing
# Grafana's ingress — otherwise the install fails fast at template time
# (GF_SERVER_ROOT_URL empty → OIDC redirect_uris break).
url: "https://grafana.example.com"Install and configure the Lunar CLI (needs LUNAR_HUB_HOST and LUNAR_HUB_TOKEN at minimum), then pull your primary configuration into the Hub:
lunar hub pull github://your-org/your-config-repo@mainThe Hub automatically registers per-repo GitHub webhooks at <hub.webhookURL>/webhooks/github when manifests are pulled. No manual webhook configuration is required as long as:
hub.webhookURLresolves to your webhooks ingress and is reachable from GitHub. When chart-managed ingress is enabled, this is derived fromhub.ingress.webhooks.hostautomatically.- The GitHub App has the
repository_hooks: writepermission (the manifest script grants this by default).
helm repo update
helm upgrade lunar earthly/lunar \
--namespace lunar \
-f values.yamlPer-release changes (breaking changes, new values, behaviour shifts)
are recorded in the chart CHANGELOG. Read
the entries between your current version and the one you're upgrading
to before running helm upgrade.
helm uninstall lunar --namespace lunarThis removes all Kubernetes resources created by the chart. The PVC for hub state is not deleted automatically — remove it manually if you want to discard all data.
Run helm show values earthly/lunar for the full, authoritative list. Defaults below are grouped by component.
Global
| Key | Description | Default |
|---|---|---|
nameOverride |
Override the chart name | "" |
fullnameOverride |
Override the full release name | "" |
clusterDomain |
Kubernetes cluster DNS domain. Override only if your cluster was provisioned with a non-default --cluster-domain |
cluster.local |
logging.level |
Log level (debug, info, warn, error) applied to both Hub and Operator |
info |
logging.format |
Log format (json or text) applied to both Hub and Operator |
json |
imagePullSecrets |
Image pull secrets for all pods | [] |
serviceAccount.create |
Create a service account | true |
serviceAccount.automount |
Automount the service account token | true |
serviceAccount.annotations |
Service account annotations (e.g. IAM role ARN) | {} |
serviceAccount.name |
Service account name (auto-generated if empty) | "" |
Hub
The central gRPC/HTTP server. Stores metadata, evaluates policies, and serves the API.
Image
| Key | Description | Default |
|---|---|---|
hub.image.repository |
Hub container image | ghcr.io/earthly/lunar-hub |
hub.image.tag |
Image tag | 2.1.1 |
hub.image.pullPolicy |
Pull policy | IfNotPresent |
hub.maxWorkers.collect |
Max Hub workers for collector queue jobs; 0 means unlimited |
10 |
hub.maxWorkers.policy |
Max Hub workers for policy queue jobs; 0 means unlimited |
20 |
hub.maxWorkers.cronCollect |
Max Hub workers for cron collector queue jobs; 0 means unlimited |
5 |
hub.maxWorkers.cataloger |
Max Hub workers for cataloger queue jobs; 0 means unlimited |
1 |
hub.extraEnv |
Additional environment variables (name/value or valueFrom pairs) |
[] |
Public URL
| Key | Description | Default |
|---|---|---|
hub.webhookURL |
External URL where GitHub posts webhooks. Chart registers <webhookURL>/webhooks/github with the GitHub App at boot. Defaults to https://<hub.ingress.webhooks.host> only when hub.ingress.enabled: true — the chart only derives a URL when it actually routes the traffic. Set explicitly when ingress is disabled (BYO) or you need a non-default scheme/port/path. When set explicitly alongside chart-managed ingress, the host portion must equal hub.ingress.webhooks.host — install fails fast otherwise. |
"" (derived) |
See also grafana.url (under Grafana) for the Grafana-side equivalent — drives both HUB_GRAFANA_URL_BASE (consumed by Hub) and GF_SERVER_ROOT_URL (consumed by Grafana).
Licence
| Key | Description | Default |
|---|---|---|
hub.licence.secretName |
Secret containing the signed Hub licence JWT | lunar-hub-licence |
hub.licence.secretKey |
Key within the licence secret | hub-licence.jwt |
hub.licence.filePath |
In-container path used for HUB_LICENCE_FILE |
/var/run/secrets/lunar/hub-licence.jwt |
Database
| Key | Description | Default |
|---|---|---|
hub.db.host |
PostgreSQL host | "" (required) |
hub.db.name |
Database name | "" (required) |
hub.db.port |
PostgreSQL port | 5432 |
hub.db.waitSecs |
Seconds to wait for DB readiness on startup | 45 |
hub.db.user.secretName |
Secret containing the DB username | lunar-db |
hub.db.user.secretKey |
Key within the secret | username |
hub.db.pass.secretName |
Secret containing the DB password | lunar-db |
hub.db.pass.secretKey |
Key within the secret | password |
hub.db.connectionOptions |
How Lunar itself reaches Postgres, as a map of connection parameters. Used by the Hub, the migrate Job and the operator, and inherited by the SQL API. Default works against managed Postgres with forced TLS (RDS, Aurora, Cloud SQL); use sslmode: disable for plain cluster-local Postgres. |
{sslmode: require} |
hub.db.sqlapiConnectionOptions |
What the Hub hands to SQL API clients (surfaced by lunar sql). Empty inherits hub.db.connectionOptions. |
{} |
hub.db.sqlapiPassword.mode |
How the sqlapi_user password is supplied — generate (the chart creates a random one and keeps it), secret (read from a Secret you manage), or unmanaged (set nothing; you pre-created the role and own its credential). See the notes below. |
generate |
hub.db.sqlapiPassword.secretName |
Secret holding the password. Required when mode: secret |
"" |
hub.db.sqlapiPassword.passwordKey |
Key within that secret | password |
Moving off
hub.extraEnv: before chart 3.19.0 the SQL API password had no chart value and arrived asHUB_SQLAPI_PASSWORDthroughhub.extraEnv. Setting it there now fails the render. Point the new value at the same secret and the password is unchanged, so connection strings already in use keep working:hub: db: sqlapiPassword: mode: secret secretName: lunar-sqlapi passwordKey: password
unmanagedis the shared-cluster case. If the Hub's DB role has noCREATEROLEand you pre-createdsqlapi_useryourself, setmode: unmanaged. The other two modes renderALTER ROLE … PASSWORDduring migration, which that role cannot execute — and because the migrate Job is a pre-upgrade hook, that failure aborts the whole upgrade. In this mode the Hub does not know the credential, so the connection string it vends carries an empty password and you supply your own.
Override footgun: setting
hub.db.connectionOptionsreplaces the whole map — the default is not merged in, so an override that only means to add something quietly takessslmodeaway with it:hub: db: connectionOptions: connect_timeout: 10 # BAD — sslmode is gone, so TLS is no longer requiredRestate
sslmodewhenever you add options:hub: db: connectionOptions: sslmode: require connect_timeout: 10Set
sqlapiConnectionOptionsonly when SQL API clients reach Postgres by a different route than the Hub does — through a connection pooler on a hostname of its own, say, which clients should verify:hub: db: sqlapiConnectionOptions: sslmode: verify-full sslrootcert: system(
sslrootcert: systemneeds libpq 16+; older clients need an explicit CA path.)
Deprecated: the string form. Either field also accepts a plain string, which reaches every consumer verbatim exactly as it did in chart 3.15.0 and earlier. Strings are removed in 4.0.0. They are worth migrating off because the consumers do not agree on a separator: the Hub, the migrate Job and the operator read libpq keyword/value pairs, space-separated, while the SQL API and the Grafana datasource receive a URL query,
&-separated. One option is valid either way — which is why a single sharedsslmode=requirenever caused trouble — and a second option is valid in exactly one, leaving the others with a value they cannot parse. The install notes flag it if yours has more than one. Written as a map, each consumer gets its own syntax.
GitHub
Hub authenticates as a GitHub App. App ID, install ID, and the App's private-key secret are all required — the chart fails at install time otherwise. See GitHub authentication.
| Key | Description | Default |
|---|---|---|
hub.github.app.owner |
GitHub org or user the App is installed on (single-App mode) (required as of 2.2.0) | "" |
hub.github.app.id |
GitHub App ID (single-App mode) | 0 |
hub.github.app.installId |
GitHub App Installation ID (single-App mode) | 0 |
hub.github.app.privateKey.secretName |
Secret containing the App private-key PEM (single-App mode) | lunar-github-app |
hub.github.app.privateKey.secretKey |
Key within the secret | private-key |
hub.github.apps |
Multi-App entries: list of {owner, appId, installId}, optionally host, baseUrl, privateKeyFile. Repeat an owner to pool several Apps over its REST budget. Mutually exclusive with hub.github.app.*. |
[] |
hub.github.appsSecret.secretName |
Secret holding one PEM key per entry (key name: apps[].privateKeyFile, default <lowercase-owner>.pem) |
lunar-github-apps |
hub.github.webhookSecret.secretName |
Secret containing the webhook secret | lunar-github-webhook |
hub.github.webhookSecret.secretKey |
Key within the secret | webhook-secret |
hub.github.baseUrl |
GitHub API base URL (for GitHub Enterprise Server) | "" |
hub.github.syncWindow |
How far back to sync GitHub data on first pull | 2160h (90 days) |
hub.gitlab.tokens |
GitLab entries: list of {group, host?, baseUrl?, webhookSecret?}, one per top-level group. May combine with GitHub; at least one forge is required. |
[] |
hub.gitlab.tokensSecret.secretName |
Secret holding one group access token per tokens[].group (key name: <lowercase-group>.token) |
lunar-gitlab-token |
hub.gitlab.webhookSecret.secretName |
Secret containing the GitLab webhook signing secret (empty = chart-generated; no paste-back — the Hub stamps it on project hooks itself) | "" |
hub.gitlab.webhookSecret.secretKey |
Key within the secret | webhook-secret |
S3 / Object Storage
| Key | Description | Default |
|---|---|---|
hub.s3.logsBucket |
S3 bucket for log storage | "" (required) |
hub.s3.resourcesBucket |
S3 bucket for script resources | "" (required) |
hub.s3.logsUrlTtl |
Pre-signed URL TTL for script log uploads — Go duration string | 5m |
hub.s3.resourcesUrlTtl |
Pre-signed URL TTL for script resource downloads (init-container fetch) | 1h |
Auth
| Key | Description | Default |
|---|---|---|
hub.auth.secretName |
Secret containing the Hub auth token | lunar-auth-token |
hub.auth.secretKey |
Key within the secret | token |
Script secrets (optional)
Per-scope secrets the hub forwards to script execution. Each scope (collector / cataloger / policy) supports two delivery shapes; pick one per scope.
Default — single key (perKey: false): the K8s Secret's secretKey data entry is a comma-separated list of NAME:VALUE pairs (envconfig map format, not JSON); each pair surfaces as LUNAR_SECRET_<NAME> in the script pod. Simplest shape, but all keys travel together: rotating one requires re-supplying the rest.
Per-key (perKey: true): every data key in the K8s Secret is mounted via envFrom: secretRef + prefix: and surfaces as HUB_<SCOPE>_SECRET_<KEY>=<value> in the hub, then re-emitted to scripts as LUNAR_SECRET_<KEY>. Operators can add or rotate a single key with kubectl edit secret / kubectl patch without touching the others — this is the recommended shape going forward. Requires hub >= 2.2.0. The hub merges both shapes if both are configured (per-key wins on conflict), so migrating one key at a time is safe.
Most installs don't need these — per-type container spec envFrom / volumes on operator.scriptContainerSpec* is usually a cleaner path. Leave secretName empty to skip injection entirely.
| Key | Description | Default |
|---|---|---|
hub.secrets.collector.secretName |
Collector secrets; empty disables | "" |
hub.secrets.collector.secretKey |
Key within the secret (single-key shape only) | secrets |
hub.secrets.collector.perKey |
Mount via envFrom + prefix: HUB_COLLECTOR_SECRET_ (per-key shape) |
false |
hub.secrets.cataloger.secretName |
Cataloger secrets; empty disables | "" |
hub.secrets.cataloger.secretKey |
Key within the secret (single-key shape only) | secrets |
hub.secrets.cataloger.perKey |
Mount via envFrom + prefix: HUB_CATALOGER_SECRET_ (per-key shape) |
false |
hub.secrets.policy.secretName |
Policy secrets; empty disables | "" |
hub.secrets.policy.secretKey |
Key within the secret (single-key shape only) | secrets |
hub.secrets.policy.perKey |
Mount via envFrom + prefix: HUB_POLICY_SECRET_ (per-key shape) |
false |
Logging
Hub logging uses the top-level global logging.* values. Tenant and telemetry routing config is loaded from the signed licence JWT mounted via hub.licence.*.
Policy queue
| Key | Description | Default |
|---|---|---|
hub.policyQueue.pollInterval |
How often the queue is polled | 1s |
hub.policyQueue.numWorkers |
Number of concurrent policy evaluation workers | 5 |
Data retention
Off by default: leave hub.retention.enabled false and the Hub keeps run
history forever. Windows are Go durations extended with d (24h) and w
(7d) — write them in days, because m is still Go's minutes, so 12m
is a twelve-minute window that passes validation and deletes essentially all
run history on the first sweep. Twelve months is 365d.
| Key | Description | Default |
|---|---|---|
hub.retention.enabled |
Master switch for the run-history sweep. Also gates the config-generation prune and cascadeEnabled; does not gate vacuumEnabled |
false |
hub.retention.window |
How far back a run stays visible. Sets HUB_RETENTION_RUNS and HUB_RETENTION_DERIVED to the same value from one key — the Hub refuses to boot when they differ. 0 keeps runs forever, and also switches the config prune and Git tier off (the sweep returns before reaching them) |
90d |
hub.retention.configWindow |
How long superseded config generations are kept (0 = forever). Only acts with cascadeEnabled true and a non-zero window; a repository's newest generation is never pruned |
90d |
hub.retention.cascadeEnabled |
Also prune superseded manifest generations and the components, domains and script definitions under them, plus unreferenced commits, pull requests and repositories. The only part of retention that deletes a component | false |
hub.retention.vacuumEnabled |
Reclaim freed disk with a threshold-gated nightly VACUUM FULL. Takes ACCESS EXCLUSIVE on each table it rewrites (readers stall, then succeed) and needs free disk of roughly the surviving data size. Independent of enabled |
false |
hub.retention.interval |
How often the sweep runs. A plain Go duration — hours/minutes/seconds only, no d or w; a daily sweep is 24h |
1h |
hub.retention.batchSize |
Rows removed per DELETE statement |
5000 |
hub.retention.maxBatchesPerRun |
Batch budget for one tick, shared across the six runs-tier tables rather than per-table — so batchSize × this is the rows-per-tick ceiling. The rate limit on the initial backfill, and the knob to lower if the first sweep hurts |
100 |
hub.retention.vacuumLockTimeout |
How long the compaction waits for ACCESS EXCLUSIVE before giving up until tomorrow. Keeps one long-running read from turning a bounded stall into an unbounded one. A plain Go duration (no d/w), and must be >= 1ms |
5s |
interval, batchSize and maxBatchesPerRun are validated at boot even
when enabled is false — each must be greater than zero, so none of them
can be zeroed to turn something off. A tick that spends its whole batch budget
on the runs tier skips the config-generation prune and the Git tier, which
during an initial backfill is every tick.
Requires a Hub image with the retention workers; on older images these
variables are simply unread. If you already set HUB_RETENTION_* through
hub.extraEnv, remove those entries when you adopt these values — otherwise
the same names are emitted twice.
Persistence
The Hub uses a PVC for state, cached repos, and script code.
| Key | Description | Default |
|---|---|---|
hub.persistence.enabled |
Create a PVC for hub state | true |
hub.persistence.storageClass |
StorageClass (empty = cluster default) | "" |
hub.persistence.size |
Volume size | 10Gi |
hub.persistence.accessModes |
PVC access modes | [ReadWriteOnce] |
Networking
| Key | Description | Default |
|---|---|---|
hub.service.type |
Service type | ClusterIP |
hub.service.ports.server |
gRPC port | 8000 |
hub.service.ports.http |
HTTP port | 8001 |
hub.ingress.enabled |
Render the hub ingresses (three resources: api-grpc, api-http, webhooks) | false |
hub.ingress.className |
Shared default ingress class. Overridden per-block when set. | "" |
hub.ingress.tls |
Shared default TLS config. Overridden per-block when set. | [] |
hub.ingress.annotations |
Shared default annotations applied to all three ingresses. | {} |
hub.ingress.api.host |
Hostname for the API ingress (gRPC + /logs). Required when enabled. |
"" |
hub.ingress.api.className |
Override ingress.className for the API ingress. |
"" |
hub.ingress.api.tls |
Override ingress.tls for the API ingress. |
[] |
hub.ingress.api.grpcAnnotations |
Annotations applied only to the api-grpc Ingress, layered on top of ingress.annotations. NGINX users need backend-protocol: GRPC here (other controllers have their own equivalent — see Ingress for examples). Controller-neutral default — set explicitly for your ingress class. |
{} |
hub.ingress.api.httpAnnotations |
Annotations applied only to the api-http (/logs) Ingress, layered on top of ingress.annotations. |
{} |
hub.ingress.webhooks.host |
Hostname for the webhooks ingress (GitHub /webhooks). Required when ingress.enabled: true. Source of the derived hub.webhookURL (only in that case — when ingress is disabled the chart does not derive from this field). |
"" |
hub.ingress.webhooks.className |
Override ingress.className for the webhooks ingress. |
"" |
hub.ingress.webhooks.tls |
Override ingress.tls for the webhooks ingress. |
[] |
hub.ingress.webhooks.annotations |
Annotations layered on top of ingress.annotations for the webhooks ingress. |
{} |
Probes
| Key | Description | Default |
|---|---|---|
hub.readinessProbe.enabled |
Enable readiness probe | true |
hub.readinessProbe.initialDelaySeconds |
Delay before first check | 0 |
hub.readinessProbe.periodSeconds |
Check interval | 5 |
hub.readinessProbe.failureThreshold |
Failures before unready | 3 |
hub.livenessProbe.enabled |
Enable liveness probe | true |
hub.livenessProbe.initialDelaySeconds |
Delay before first check | 0 |
hub.livenessProbe.periodSeconds |
Check interval | 5 |
hub.livenessProbe.failureThreshold |
Failures before restart | 3 |
Scheduling & pod spec
| Key | Description | Default |
|---|---|---|
hub.resources |
CPU/memory requests and limits | {} |
hub.migrateJobResources |
CPU/memory requests and limits for the pre-install/pre-upgrade migrate Job's container | {} |
hub.migrateJobActiveDeadlineSeconds |
Backstop that reaps a wedged pre-install/pre-upgrade migrate Job — not a budget for how long migrations may take. Keep it above your Helm timeout, which is what actually bounds how long an upgrade waits | 3600 |
hub.migrateJobBackoffLimit |
Ceiling on migrate Job retries. This is the retry for transient lock contention; Kubernetes backs off exponentially (10s, doubling, capped at 6m), putting attempts at roughly t = 0, 12, 32, 72, 152, 312s. Your Helm timeout bounds this before migrateJobActiveDeadlineSeconds does — at the 5m default only five attempts fit |
10 |
hub.nodeSelector |
Node selector | {} |
hub.tolerations |
Tolerations | [] |
hub.affinity |
Affinity rules | {} |
hub.labels |
Additional deployment labels | {} |
hub.annotations |
Additional deployment annotations | {} |
hub.podLabels |
Additional pod labels | {} |
hub.podAnnotations |
Additional pod annotations | {} |
hub.podSecurityContext |
Pod security context | {} |
hub.securityContext |
Container security context | {} |
hub.volumeMounts |
Additional volume mounts | [] |
hub.volumes |
Additional volumes | [] |
Operator
Watches for script execution jobs and creates Kubernetes pods to run them.
Images
| Key | Description | Default |
|---|---|---|
operator.image.repository |
Operator image | ghcr.io/earthly/lunar-snippet-operator |
operator.image.tag |
Image tag | 2.1.1 |
operator.image.pullPolicy |
Pull policy | IfNotPresent |
operator.initImage.repository |
Init container image | ghcr.io/earthly/lunar-snippet-init |
operator.initImage.tag |
Image tag | 2.1.1 |
operator.sidecarImage.repository |
Sidecar container image | ghcr.io/earthly/lunar-snippet-sidecar |
operator.sidecarImage.tag |
Image tag | 2.1.1 |
Behavior
| Key | Description | Default |
|---|---|---|
operator.scriptNamespace |
Namespace for script pods (must exist if set) | "" (release namespace) |
operator.hubHost |
Override hostname the operator and script pods use to reach Hub gRPC. Empty = computed in-cluster FQDN, which resolves cross-namespace. Set only for service-mesh / split-DNS / multi-cluster topologies | "" |
operator.maxConcurrent |
Max concurrent script pods | 10 |
operator.healthPort |
Operator health check port | 8081 |
operator.extraEnv |
Additional environment variables | [] |
Script pod configuration
| Key | Description | Default |
|---|---|---|
operator.scriptContainerSpecPolicy |
Base container spec for policy script pods (resources, securityContext, env, etc.) | {} |
operator.scriptContainerSpecCollector |
Base container spec for collector script pods | {} |
operator.scriptContainerSpecCataloger |
Base container spec for cataloger script pods | {} |
operator.batchMaxCountPolicy |
Max jobs per policy pod; 0 uses the operator default |
0 |
operator.batchMaxCountCollector |
Max jobs per collector pod; 0 uses the operator default |
0 |
operator.batchMaxCountCataloger |
Max jobs per cataloger pod; 0 uses the operator default |
0 |
operator.scriptPodNodeSelector |
Node selector for script pods | {} |
operator.scriptPodTolerations |
Tolerations for script pods | [] |
operator.scriptPodPriorityClassName |
PriorityClass name for script pods; empty leaves the cluster default | "" |
operator.scriptPodAnnotations |
Annotations applied to script pods | {} |
operator.scriptPodSecurityContext |
Pod-level securityContext for script pods (fsGroup, runAsUser, seccompProfile, …); container-level context comes from scriptContainerSpec* |
{} |
operator.scriptPodTopologySpreadConstraints |
Topology-spread constraints for script pods. Prefer whenUnsatisfiable: ScheduleAnyway; labelSelector defaults to the operator's own script pods. Distinct from operator.topologySpreadConstraints, which spreads the operator Deployment's replicas |
[] |
Logging
Operator logging uses the top-level global logging.* values. Tenant and telemetry routing config is fetched at runtime from the Hub (GetRuntimeConfig).
Scheduling & pod spec
| Key | Description | Default |
|---|---|---|
operator.resources |
CPU/memory requests and limits | {} |
operator.nodeSelector |
Node selector | {} |
operator.tolerations |
Tolerations | [] |
operator.affinity |
Affinity rules | {} |
operator.podLabels |
Additional pod labels | {} |
operator.podAnnotations |
Additional pod annotations | {} |
operator.podSecurityContext |
Pod security context | {} |
operator.securityContext |
Container security context | {} |
Grafana
Pre-built Grafana instance with dashboards for policy results, component health, and collection activity. Deployed by default; set grafana.mode: off to opt out.
| Key | Description | Default |
|---|---|---|
grafana.mode |
How Grafana is provided: chart (bundled pod + dashboards), external (bring-your-own Grafana + dashboards), or off (neither) |
chart |
grafana.url |
URL where Grafana is reachable (was grafana.externalURL). Drives GF_SERVER_ROOT_URL (Grafana's self-knowledge — used for OIDC redirect_uri, absolute link rendering, etc) and HUB_GRAFANA_URL_BASE ([More Details] links in PR comments). In chart mode defaults to https://<grafana.ingress.hosts[0].host> when chart-managed Grafana ingress is enabled (else required); in external mode it's required (the external target). Install fails fast if unset when required. |
"" (derived) |
grafana.image.repository |
Grafana image | ghcr.io/earthly/lunar-grafana |
grafana.image.tag |
Image tag | 2.1.1 |
grafana.auth.secretName |
Grafana credentials (was grafana.admin). chart mode: admin login — empty = chart auto-generates <release>-grafana-admin (kept across uninstall). external mode: required — the secret the Hub authenticates to your Grafana with |
"" |
grafana.auth.userKey |
Key within the secret holding the username (basic auth) | username |
grafana.auth.passwordKey |
Key within the secret holding the password (basic auth) | password |
grafana.auth.tokenKey |
Key holding a service-account token; when set (external mode only), the Hub uses token auth instead of basic user/password | "" |
grafana.anonymousViewer |
Serve Grafana with no login — unauthenticated visitors get the Viewer role and land on the dashboards; the admin login form stays available for Editor/Admin. chart mode only (install fails fast elsewhere, since it's a setting on the bundled pod). Only enable when Grafana isn't reachable from the internet: a Grafana Viewer can query every provisioned datasource directly, so this grants anyone who can reach the Service read access to the Lunar database |
false |
grafana.service.type |
Service type | ClusterIP |
grafana.service.port |
Service port | 80 |
grafana.ingress.* |
Same structure as hub.ingress.* |
disabled |
grafana.extraEnv |
Additional environment variables | [] |
grafana.replicaCount |
Number of Grafana replicas. Requires grafana.db.host (below) when > 1 — install fails fast otherwise, since the default per-pod SQLite backend can't be shared across replicas |
1 |
grafana.db |
Grafana's own backend store (sessions, orgs, annotations — NOT the read-only dashboard datasource, see grafana.provisioning.dbPassword). Empty keeps the built-in SQLite (single-replica only); set host/port/name/sslMode plus user/pass secret refs ({secretName, secretKey} — see values.yaml for the full shape) to point it at Postgres and share state across replicas |
{} |
grafana.resources |
CPU/memory requests and limits for the Grafana server container | {} |
grafana.securityContext |
securityContext for the Grafana server container | {} |
grafana.kiosk.image.repository / grafana.kiosk.image.tag |
Image for the kiosk sidecar (the nginx proxy that injects ?kiosk into dashboard URLs) |
nginx / 1.31.3-alpine |
grafana.kiosk.resources |
CPU/memory requests and limits for the kiosk sidecar | {} |
grafana.kiosk.securityContext |
securityContext for the kiosk sidecar — separate from grafana.securityContext since it runs as a different uid |
{} |
grafana.nodeSelector |
Node selector | {} |
grafana.tolerations |
Tolerations | [] |
grafana.affinity |
Affinity rules | {} |
grafana.topologySpreadConstraints |
Pod topology-spread constraints for Grafana replicas. Empty is NOT "no spread" — the chart applies a soft default (maxSkew: 1 across kubernetes.io/hostname, ScheduleAnyway); set this to override wholesale |
[] |