Prerequisites
Before you run helm install lunar, the external dependencies below must be in place. The install walkthrough assumes you have them.
If you want a total picture of what you're about to deploy, read the overview first.
Lunar Hub is supported on Kubernetes only. Bare-metal and Docker installations are not supported.
Install the Lunar CLI first. The walkthrough uses it on your workstation to derive the cluster's GHCR image-pull secret from your licence — before helm install.
Before you begin
Make sure you have the following available before continuing.
DNS hostnames + TLS certs
One hostname for the Hub (reachable from your Git platform — github.com, GHES, gitlab.com, or your GitLab instance), plus a second for Grafana if you let the chart run it for you (reachable from your users)
Git platform admin access
GitHub org admin (or a personal account) to create a GitHub App, and/or GitLab group Maintainer/Owner to create a group access token
If you're standing up EKS from scratch, earthly/lunar-terraform-quickstart is a working reference module you can fork if desired.
Step 1 — Plan your Kubernetes namespaces
We recommend splitting the install across two namespaces:
Control-plane namespace (e.g.
lunar). This is the release namespace. It hosts the Lunar Hub, the Operator, and — if you use the chart's bundled Grafana — Grafana. These are the parts that need to stay up, and will be updated by Helm.Run Pods namespace (e.g.
lunar-scripts). This hosts the short-lived pods the operator spawns to execute cataloger, collector, and policy batches. We recommend this separate namespace because:this workload is ephemeral, and can be rather "bursty" in number and resource requirements. You can tune resources and limits independently here.
this code is user-supplied (e.g. your plugins, scripts, third-party catalogers), not Lunar's. This is a different trust boundary, where you can tighten RBAC, egress, and resource limits independently.
Both namespaces must exist before helm install. The chart will not create the run-pods namespace for you. Point the operator at it with operator.snippetNamespace.
Single-namespace installs also work — leave operator.snippetNamespace unset and everything runs in the release namespace. This is fine for trying things out, or small setups; but is not recommended for production configurations.
Step 2 — Check your Kubernetes cluster
Kubernetes
1.29 or newer.
Helm
3.x.
StorageClass
A StorageClass must be available — your cluster default is probably fine. The chart provisions a 10 GiB ReadWriteOnce PVC for Hub state. You can tune details if needed via hub.persistence.* (chart README).
Ingress controller
Must support gRPC backend routing. See Ingress in the chart README for an NGINX-tested example.
DNS
A hostname for the Hub (e.g. lunar.example.com) pointing at your ingress controller's external IP — your Git platform, CI integrations, and Lunar CLI users must reach it. If you let the chart run Grafana for you, add a second hostname for it (e.g. grafana.lunar.example.com) for your team's browser access; not needed if you point Lunar at your own Grafana.
TLS certificate
The Hub (and the bundled Grafana, when enabled) listen plaintext. Terminate TLS at your ingress or an upstream load balancer for each hostname.
Step 3 — Provision PostgreSQL
Lunar needs a single PostgreSQL database. Schema migrations run as a pre-rollout Job on each release, and the Hub manages several of its own schemas (including the default public). We recommend that you give it a dedicated DB where its role is the owner.
Version
PostgreSQL 16 or newer.
Connectivity
Reachable from both the Hub and Operator pod's network. The chart does not include Postgres.
Role
Dedicated DB role for the Hub. Minimum: database owner (to create schemas, and grants on created objects) plus cluster-level CREATEROLE (to create the read-only sqlapi_user and grafana_user roles during migration). SUPERUSER also works.
Extensions
Optional: pg_stat_statements enabled in shared_preload_libraries. The Hub's diagnostics bundle uses it when present. Setting shared_preload_libraries requires a Postgres restart (or parameter-group reboot on RDS / Cloud SQL), so it's easier to enable at provisioning time than later.
Connection pool
The Hub is stateless and runs as multiple replicas; each opens ~85 connections by default (maxOpenConns + maxPoolConns + operatorPoolSize), so total connections scale with hub.replicaCount. Size your Postgres (or PgBouncer) for replicaCount × per-replica pool. See Scaling in Day 2 Operations for tuning context.
SSL
The Hub negotiates TLS by default via hub.db.connectionOptions: "sslmode=require". Most managed Postgres (RDS, Aurora, Cloud SQL) ships with TLS forced and will connect out of the box. Plain Postgres deployments without TLS must set hub.db.connectionOptions: "sslmode=disable" explicitly. Format is libpq KV pairs, space-separated — to pass extra options write "sslmode=require connect_timeout=10" (NOT &-separated URL query). The default is not merged in when overridden, so include sslmode= yourself.
Backups
Use your existing Postgres backup process; backups are your responsibility. All authoritative Hub and Operator state lives entirely in Postgres.
Shared Postgres cluster? Because CREATEROLE is a cluster-level attribute, Postgres has no mechanism to scope it to a single database. If granting it cluster-wide is too broad, pre-create the sqlapi_user role yourself (any password) and leave HUB_SQLAPI_PASSWORD unset. The migration's IF NOT EXISTS check skips role creation, leaving only schema-level grants — which the Hub's role can do as database owner.
Step 4 — Provision S3-compatible object storage
Lunar needs two private S3 buckets, both writable by the Hub:
A Logs bucket, which contains per-run log files. These can be short-lived; a 30-day lifecycle rule is reasonable here.
A Resources bucket, which contains run-bundle archives fetched by init containers. Keep these as long as you might re-run historical catalogers, collectors, or policies.
Both buckets must block public access. Content may include credentials, user script source code, or PII surfaced from CI runs. The Hub serves all reads via time-limited pre-signed URLs.
Object lifecycles are your responsibility. Lunar never deletes from either bucket. Set S3 lifecycle rules yourself to cap storage growth. Check with your compliance requirements before settling on retention windows, since logs and run bundles may contain information that falls under your organization's data-retention policies.
The Hub only calls PutObject, GetObject, and HeadObject on both buckets, and issues pre-signed GET/PUT URLs for both. Minimum IAM policy on AWS:
Region and credentials
The Hub picks up AWS credentials via the standard SDK credential chain — the chart stays out of the credentials business. Region must be set explicitly:
Common patterns (see the chart README for full YAML):
EKS. Annotate the chart's service account with an IAM role for service accounts.
Static credentials. Inject
AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEYfrom a secret viahub.extraEnv. Not recommended for production.IMDS, pod identity, external secrets operators. All the usual AWS-SDK-friendly mechanisms work.
Non-AWS backends
MinIO, Cloudflare R2, and GCS in S3-compatibility mode all work — set AWS_ENDPOINT_URL_S3 via hub.extraEnv:
Lunar uses virtual-host-style S3 addressing only. Most MinIO, R2, and GCS deployments handle this out of the box. Path-style-only backends aren't currently supported.
Step 5 — Connect your Git platform
Lunar needs credentials for the Git platform your components live on. At least one is required: the Hub refuses to start with neither configured. Set up both if you run both.
Read Git Platforms before you start. Most of Lunar behaves identically on GitHub and GitLab; merge gating is one exception.
The Hub authenticates as a GitHub App you create and install on your org. The hosted setup tool creates one in a couple of clicks:
Follow the prompts. The tool uses GitHub's manifest flow to register the App with the right permissions and events.
Download the PEM private key when prompted. GitHub shows it exactly once — if you click past this page, you'll have to generate a new key from the App settings later.
Click Install App on GitHub and select the org. Choose All repositories unless you have a specific reason not to — Lunar's actual monitoring scope is configured in
lunar-config.yml, so a narrower scope here just means coming back to Org Settings → GitHub Apps → Lunar → Repository access every time you add a new repo to Lunar.
The hosted tool proxies the manifest exchange to GitHub and returns the credentials to your browser; we never persist them.
Need to create the App by hand? On GitHub Enterprise Server, or when earthly.dev is unreachable from your browser, follow manual setup, which produces the same App. The permissions and events it is granted are documented for security review whichever flow you use.
Capture these four before continuing:
Owner (the GitHub org or user the App is installed on, e.g. earthly)
The <org> in the install URL
App ID (numeric, e.g. 3635822)
Setup tool result page, or the App settings page
Installation ID (numeric)
The trailing number in the URL after install: https://github.com/organizations/<org>/settings/installations/<INSTALL_ID>
PEM private key
Downloaded when the key was generated. GitHub shows it once, so save it now
They become HUB_GITHUB_APP_OWNER, HUB_GITHUB_APP_ID, HUB_GITHUB_APP_INSTALL_ID, and HUB_GITHUB_APP_PRIVATE_KEY (or the chart equivalents under hub.github.app.*) in install Step 4. All four are required together; a partial App configuration is rejected at startup.
Fronting several orgs from one Hub takes a different shape. See multiple organizations.
GitLab has no App model, so the Hub authenticates with a group access token, one per top-level group, which also covers every subgroup and project beneath it. GitLab → Create the token walks through creating it, and through the one choice that has consequences: which account it belongs to.
Capture these before continuing:
Group access token
api scope, Maintainer or Owner role, created on your top-level group
Group path
The top-level group the token was created on, e.g. acme
Host
gitlab.com, or your instance hostname if self-managed
They become one HUB_GITLAB_TOKENS entry in install Step 4. Serving several top-level groups from one Hub? See multiple groups.
Self-managed GitLab must be listed by host. Lunar decides whether a host is GitLab from this configuration, so note your instance hostname now. An omitted host is treated as GitHub, and every operation against it then fails confusingly. gitlab.com is recognized without configuration.
Step 6 — Plan your Kubernetes secrets
Several Kubernetes secrets come into play at install time. You create the first four below; the chart auto-generates the rest (with helm.sh/resource-policy: keep, so they survive upgrades and uninstalls).
lunar-db
You
DB username and password
lunar-github-app
You (GitHub only)
private-key — the PEM from Step 5
lunar-gitlab-token
You (GitLab only)
token — the group access token from Step 5, mounted as a file
lunar-hub-licence
You
hub-licence.jwt — Hub licence token
regcred
You (derived from licence)
GHCR image-pull credentials — generated by lunar licence pull-secret from your licence JWT
<release>-auth-token
Chart (auto-generated)
Shared bearer token for the CLI and CI tracers
<release>-github-webhook
Chart (auto-generated)
Webhook signing secret for GitHub — the Hub registers it automatically when it creates per-repo webhooks
<release>-gitlab-webhook
Chart (auto-generated)
The same, for GitLab project hooks. Override it per group with a webhook_secret in HUB_GITLAB_TOKENS, or per App with one in HUB_GITHUB_APPS on the GitHub side
<release>-grafana-admin
Chart (auto-generated)
Grafana admin username and password — only when the chart runs Grafana for you (Option A)
<release>-grafana-db
Chart (auto-generated)
Read-only grafana_user DB-role password (used by the dashboards' datasource) — whenever the provisioning Job runs (bundled or your own Grafana). BYO via grafana.provisioning.dbPassword.secretName.
Pointing Lunar at your own Grafana (Option B or C)? The chart doesn't generate <release>-grafana-admin — you create a secret holding your Grafana service-account token (or admin username/password). The <release>-grafana-db secret above is still chart-managed. See Install Step 4 → Grafana.
Your licence also covers the CI tracer. The credential the chart uses to pull Lunar's container images — derived from your licence with lunar licence pull-secret for the regcred secret above — is also what the Hub uses to fetch the CI tracer binary for your runners. You don't need a separate credential for the CI tracer.
If your licence doesn't include CI tracer distribution, the Lunar CLI reports a clear error the first time a runner tries to download the tracer. If you plan to use the CI tracer, make sure your licence covers it — contact Earthly if you're unsure.
GitOps alternative. If your setup needs deterministic secret management, you can pre-create any chart-managed secret and point the chart at it (e.g. hub.github.webhookSecret.secretName). See the chart README for the full list of *.secretName values you can override.
The install walkthrough has the exact kubectl create secret commands for the three user-created secrets.
Step 7 — Size for capacity
The chart sets no default resource requests or limits. The numbers below are reasonable starting points, but you should monitor and adjust them based on your specific needs.
Hub
500m
1 Gi
Operator
100m
128 Mi
Run pods are short-lived batch pods spawned by the operator. Each pod contains N user containers (one per script in the batch) plus an init container and a sidecar. Per-user-container resources come from operator.snippetContainerSpec* — the operator's built-in defaults request 250m / 256 Mi for collectors and catalogers, 50m / 128 Mi for policies.
Batch size is per script type via operator.batchMaxCount* (defaults: 10 for collectors and catalogers, 20 for policies — policies pack denser because each container is lighter). Concurrent batch pods are capped by operator.maxConcurrent (default 10), shared across script types.
Hub workers that feed run pods can be capped with HUB_MAX_WORKERS_COLLECT, HUB_MAX_WORKERS_POLICY, HUB_MAX_WORKERS_CRON_COLLECT, and HUB_MAX_WORKERS_CATALOGER; 0 means unlimited. Tune these when increasing operator.maxConcurrent. The Hub and operator have separate Postgres pool caps for operator work via HUB_MAX_OPERATOR_POOL_SIZE and OPERATOR_MAX_POOL_SIZE.
The run-pods namespace needs headroom for:
At defaults that's ~25 GiB of concurrent-run memory per script type (10 pods × 10 collectors × 256 Mi, or 10 × 20 × 128 Mi for policies).
If you want sizing guidance once you're past initial install, please reach out.
Next steps
When your prerequisites are in place:
Install walkthrough — step-by-step from zero to a working Hub.
Chart README — complete
values.yamlreference.
Last updated
