> 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/dedicated/privatelink-inbound.md).

# PrivateLink to Your Hub

Reach your Lunar Dedicated hub over a private AWS PrivateLink path from your own VPC, by creating one interface endpoint and two DNS records.

Your CI runners, CLI users, and browsers all need to reach your Lunar Dedicated install, and PrivateLink is the recommended way to let them. The hub API and the UI answer only inside the networks you connect to them, and nothing is published to the internet. The alternative, a public hostname behind TLS and token authentication, is there for teams whose CI runs on shared hosted runners and so cannot reach a private endpoint.

This page is the setup guide for that path. It's written for whoever runs your AWS networking. You can forward it on its own.

{% hint style="info" %}
This page covers reaching the hub **in**. Lunar reaching **out** to your internal systems (a self-managed Git platform, an internal registry or API) is a separate, independent setup, covered in [PrivateLink to Your Internal Systems](/install/lunar-hub/dedicated/privatelink-outbound.md). Many installs need both.
{% endhint %}

## Do you need this?

Three questions to decide.

**1. Does your CI run in a network you control?** PrivateLink reaches your install from a VPC, so self-hosted runners in AWS are the natural fit. Shared hosted runners (github.com's, gitlab.com's) aren't in your VPC and can't use the endpoint, so an install that serves those still needs the internet route for whatever they call.

**2. How will your Git platform deliver webhooks?** Your Git platform calls the hub every time someone pushes, and it isn't on your runner network, so the endpoint you're about to create won't carry its traffic. This is the part most likely to have lead time, so settle it first: see [Decide the webhook path](#decide-the-webhook-path).

**3. How will developers reach the UI?** The UI is Grafana, on your install's apex hostname, and a private install resolves only inside the VPCs you connect to it. If your developers already work behind a VPN, Direct Connect, or Transit Gateway that reaches the VPC, PrivateLink covers them once you add DNS forwarding and a route ([step 5](#step-5-reach-the-install-from-elsewhere)). If they don't, you would have to build that path first, and an install on the internet, where Grafana sits behind a login, may be the better trade.

## How it works

Earthly publishes your install as an **endpoint service**. You create a matching **interface endpoint** in your VPC, plus two DNS records so your install's normal hostnames resolve to it.

```mermaid
flowchart LR
  subgraph Customer["Your AWS account"]
    CI["CI runners · CLI users<br/>browsers"]
    EP["Interface VPC endpoint<br/>(you create)"]
    CI --> EP
  end
  subgraph Dedicated["Lunar Dedicated account (Earthly-managed)"]
    ES["Endpoint service<br/>(Earthly creates)"]
    LB["Internal proxies"]
    HUB["Lunar Hub · Grafana"]
    ES --> LB --> HUB
  end
  EP -- "AWS PrivateLink (one-way, you initiate)" --> ES
```

Three properties are worth knowing up front, because they answer most security-review questions:

* **The connection is one-way.** Your side initiates every session. Nothing in the install can initiate a connection back into your VPC.
* **Neither side learns the other's addresses.** PrivateLink does not join the networks, add routes, or expose either CIDR range. Overlapping address ranges are fine.
* **You reach only the service Earthly publishes.** Not the VPC it lives in, not anything else in Earthly's account.

You own and pay for the endpoint, the hosted zone, and any resolver infrastructure in [step 5](#step-5-reach-the-install-from-elsewhere).

{% hint style="warning" %}
**PrivateLink is not authentication.** It controls which AWS account may *create* an endpoint; it does not identify individual workloads or requests. The hub API still requires its bearer token.

**The UI has no login on a private install.** Grafana is read-only to anyone who can reach the endpoint, so the endpoint's security group is what limits who sees it. Scope it to the systems and people you intend, and treat reaching the endpoint as equivalent to being able to read the UI.

Lunar Dedicated does not support mutual TLS today.
{% endhint %}

## Checklist

If you've consumed endpoint services before, this is all you need. The [Prefer Terraform?](#prefer-terraform) section has the resource definitions.

1. Decide the [webhook path](#decide-the-webhook-path). Most likely to have lead time, so start it first.
2. Interface endpoint against Earthly's service name, in subnets whose **zone IDs** Earthly supports.
3. **Private DNS disabled** on that endpoint. Leaving it on fails endpoint creation.
4. Endpoint security group: inbound TCP **443** from your systems.
5. Private hosted zone for your install's domain, associated with that VPC.
6. Two **alias A records** in it, apex and wildcard, both targeting the endpoint's **regional** DNS name.
7. [Confirm it works](#confirm-it-works) and tell Earthly when you're good to go.

## What you need from Earthly first

Earthly builds its side first, so you'll receive these before you start:

| Value                               | Example                                                   |
| ----------------------------------- | --------------------------------------------------------- |
| **Region**                          | `us-east-2`                                               |
| **Endpoint service name**           | `com.amazonaws.vpce.us-east-2.vpce-svc-0123456789abcdef0` |
| **Supported availability zone IDs** | `use2-az1`, `use2-az2`                                    |
| **Your install's domain**           | `you.dedicated.earthly.dev`                               |
| **Hub hostname**                    | `hub.you.dedicated.earthly.dev`                           |
| **Port**                            | TCP 443                                                   |

Lunar Dedicated supports same-region endpoints only, so build everything here in the install's region.

Earthly also allowlists your AWS account beforehand, so your endpoint connects without an approval step. If your security team would rather Earthly allowlist one specific IAM role, send its ARN. It must be **pathless**, because AWS rejects principal ARNs containing a path on endpoint services.

## Four names that look alike

Four names come up below. Three of them are `vpce` strings that are easy to mix up, and the fourth is what your CI actually calls.

| Name                           | Looks like                                                                                | Used for                                                            |
| ------------------------------ | ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------- |
| **Endpoint service name**      | `com.amazonaws.vpce.us-east-2.vpce-svc-0123456789abcdef0`                                 | Earthly gives you this; it's the input when you create the endpoint |
| **Endpoint ID**                | `vpce-0123456789abcdef0`                                                                  | your endpoint, once created                                         |
| **Endpoint regional DNS name** | `vpce-0123456789abcdef0-a1b2c3d4.vpce-svc-0123456789abcdef0.us-east-2.vpce.amazonaws.com` | the target your DNS records alias to                                |
| **Your install's hostnames**   | `you.dedicated.earthly.dev`, `hub.you.dedicated.earthly.dev`                              | what your CI and browsers actually call                             |

## Decide the webhook path

Your Git platform delivers webhooks to the hub. Without them, pushes don't trigger anything and pull/merge-request status checks never post. Your Git platform isn't on your runner network, so **the endpoint you're about to create won't carry that traffic.**

Which case applies depends on where your platform runs:

* **A cloud-hosted Git platform** (github.com, gitlab.com, etc.) delivers from the public internet and can't use PrivateLink. Earthly can leave *just* the webhook listener on the internet while everything else stays private. Webhooks are authenticated by a signed secret shared with the platform.
* **A self-managed Git platform in your own network** can usually reach the hub through the same endpoint, or through a second one in whichever VPC it runs in. The steps are the same either way.
* **A managed single-tenant Git platform** runs in your vendor's account. Some vendors offer an outbound private connection to a customer-published endpoint service; if yours does, request it and send Earthly the principal it gives you. **This is usually a support ticket with a lead time you don't control**, so raise it as soon as you have the endpoint service name. It runs in parallel with everything else here.

## Step 1 — Create the interface endpoint

In your runner VPC: **VPC → Endpoints → Create endpoint → Other endpoint services**, paste Earthly's service name, verify it, then choose subnets.

**Subnets must be in the zone IDs Earthly listed.** AWS maps zone *names* like `us-east-2a` to different physical zones in different accounts, so a name means nothing across an account boundary:

```bash
aws ec2 describe-availability-zones \
  --query 'AvailabilityZones[].[ZoneName,ZoneId]' --output table
```

Use two of the supported zones for redundancy; one is enough if that's all you have. If none of your subnets land in a supported zone, talk to Earthly before creating anything, because either side may need to add a subnet in a shared zone.

This constrains **placement only**: workloads anywhere in the VPC reach the endpoint across zones by ordinary VPC routing.

Attach a security group allowing inbound **TCP 443** from your runners.

## Step 2 — Turn private DNS off

Leave **Enable DNS name** unchecked (`private_dns_enabled = false` in Terraform).

Some tools default it on, and endpoint creation then fails with:

```
Private DNS can't be enabled because the service ... does not provide a private DNS name.
```

Enabling it asks AWS to resolve a hostname the *provider* has verified, and Earthly deliberately publishes none. Handling DNS on your side is what lets a single endpoint serve every hostname your install has today or gains later.

## Step 3 — Create the private hosted zone

Create a **private hosted zone** named after your install's domain (`you.dedicated.earthly.dev`), associated with the VPC holding the endpoint. That zone is dedicated to this install, so shadowing all of it is safe.

Add **two alias A records** with the same target. You need both, because a wildcard doesn't match its own apex:

| Record                        | Serves                     |
| ----------------------------- | -------------------------- |
| `you.dedicated.earthly.dev`   | the Grafana UI             |
| `*.you.dedicated.earthly.dev` | `hub.` and everything else |

**The alias target is your endpoint's regional DNS name**, the third row of [Four names that look alike](#four-names-that-look-alike). Find it on the endpoint's **Details** tab under **DNS names**, or:

```bash
aws ec2 describe-vpc-endpoints --vpc-endpoint-ids vpce-0123456789abcdef0 \
  --query 'VpcEndpoints[0].DnsEntries' --output table
```

{% hint style="warning" %}
**Two ways to get the target wrong.**

Pick the **regional** entry, not one of the per-zone variants ending `...-us-east-2a...`. A zonal target pins all your traffic to one availability zone.

And alias to the **endpoint**, not to the hostname you're creating the record for, and not to Earthly's endpoint *service* name.
{% endhint %}

Use alias records rather than CNAMEs, because a CNAME isn't valid at a zone apex.

## Step 4 — Point your CI at the hub

Nothing about Lunar's configuration changes for a private install:

```bash
LUNAR_HUB_HOST=hub.you.dedicated.earthly.dev
LUNAR_HUB_TOKEN=...
```

The gRPC API and the HTTP endpoints share port 443, so only one port ever needs to be open. Authentication is the same bearer token you'd use over the internet.

## Step 5 — Reach the install from elsewhere

**Another VPC**: repeat steps 1 to 3 in it. Each VPC gets its own endpoint and its own copy of the two records; the hostnames stay the same everywhere.

**Laptops on the corporate network** need two separate things, and it's easy to do only the first:

1. **DNS**: forward `you.dedicated.earthly.dev` into the VPC, typically via a Route 53 Resolver inbound endpoint plus a conditional forwarder on your corporate resolvers.
2. **Network**: a route from the corporate network to the endpoint's addresses (VPN, Direct Connect, or Transit Gateway), and the endpoint's security group must allow that source.

Resolver forwarding only makes the name resolve. Do the first without the second and the browser gets the right address with no way to reach it.

## Prefer Terraform?

Steps 1 to 3 are five resources:

```hcl
resource "aws_security_group" "lunar_hub" {
  name   = "lunar-hub-endpoint"
  vpc_id = var.vpc_id
}

resource "aws_vpc_security_group_ingress_rule" "lunar_hub_tls" {
  security_group_id = aws_security_group.lunar_hub.id
  ip_protocol       = "tcp"
  from_port         = 443
  to_port           = 443
  cidr_ipv4         = var.runner_cidr # or referenced_security_group_id
}

resource "aws_vpc_endpoint" "lunar_hub" {
  vpc_id             = var.vpc_id
  vpc_endpoint_type  = "Interface"
  service_name       = "com.amazonaws.vpce.us-east-2.vpce-svc-0123456789abcdef0"
  subnet_ids         = var.subnet_ids # subnets in the zone IDs Earthly supports
  security_group_ids = [aws_security_group.lunar_hub.id]

  # Required: Earthly publishes no verified private DNS name. See step 2.
  private_dns_enabled = false
}

resource "aws_route53_zone" "lunar" {
  name = "you.dedicated.earthly.dev"

  vpc {
    vpc_id = var.vpc_id
  }
}

resource "aws_route53_record" "lunar" {
  for_each = toset(["you.dedicated.earthly.dev", "*.you.dedicated.earthly.dev"])

  zone_id = aws_route53_zone.lunar.zone_id
  name    = each.value
  type    = "A"

  alias {
    name                   = aws_vpc_endpoint.lunar_hub.dns_entry[0].dns_name
    zone_id                = aws_vpc_endpoint.lunar_hub.dns_entry[0].hosted_zone_id
    evaluate_target_health = false
  }
}
```

{% hint style="warning" %}
**AWS doesn't document the order of `dns_entry`,** so `[0]` being the regional entry is a convention rather than a guarantee. Output it and check the value has no availability zone in it before relying on this in production.
{% endhint %}

## Confirm it works

From a host inside the VPC:

```bash
getent hosts hub.you.dedicated.earthly.dev
curl -sS https://you.dedicated.earthly.dev/api/health
```

The first should return the endpoint's **private** addresses; public addresses mean your hosted zone isn't answering and traffic would be leaving your network. The second should return JSON.

{% hint style="info" %}
**An endpoint reaching `available` proves less than it sounds.** It means the PrivateLink connection was established, not that DNS is right and not that anything behind it works. The two commands above are the real check.
{% endhint %}

Then tell Earthly three things, so the install can be verified end to end from both sides:

* **Your endpoint ID**, so Earthly can confirm the connection is accepted and healthy rather than pending or rejected.
* **Which zone IDs you placed it in**, which is what Earthly checks against if connectivity works from some subnets but not others.
* **Which webhook case applies**, plus any principal to allowlist for a vendor-managed connection.

## Troubleshooting

**Endpoint creation fails on private DNS.** `Private DNS can't be enabled because the service ... does not provide a private DNS name` means the flag is on. See [step 2](#step-2-turn-private-dns-off).

**Endpoint creation fails on availability zones.** Your subnets aren't in a zone the service supports. Compare zone **IDs**, not names.

**Endpoint creation is rejected outright.** Your account isn't allowlisted, or a different one is. An unlisted account can't see the service at all, so the failure never reaches Earthly's side. Confirm the account ID.

**The endpoint sits in `pendingAcceptance`.** Earthly's service accepts allowlisted connections automatically, so tell Earthly rather than waiting.

**Hostnames don't resolve, or resolve to public addresses.** The hosted zone isn't associated with the VPC you're testing from, or the records are missing. Test from inside that VPC; laptops need [step 5](#step-5-reach-the-install-from-elsewhere).

**Hostnames resolve but connections hang.** Check the endpoint's security group allows TCP 443 from the client.

**Only the apex works, or only `hub.` works.** Only one of the two records exists, and both are needed.

**Traffic works from one availability zone only.** The alias targets a zonal endpoint DNS name rather than the regional one. See [step 3](#step-3-create-the-private-hosted-zone).

**A deeper hostname resolves but fails TLS.** DNS wildcards match at any depth, so `a.b.you.dedicated.earthly.dev` resolves via your wildcard record, but the install's certificate only covers one level. Lunar only ever uses single-label names like `hub.`, so this only appears if something is calling a name Lunar doesn't publish.

**Everything works except webhooks.** Your Git platform reaches the hub by a different path from your runners. See [Decide the webhook path](#decide-the-webhook-path).


---

# 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/dedicated/privatelink-inbound.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.
