> 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-outbound.md).

# PrivateLink to Your Systems

Give your Lunar Dedicated install a private path to internal systems (an internal Git server, registry, or API) by publishing them as an AWS PrivateLink endpoint service.

A Lunar Dedicated install reaches outwards to the systems it works with. It calls your **Git platform** to read repositories and post results, and its collectors, catalogers, and policies reach whatever they gather data from, such as registries, ticketing systems, and internal APIs. When one of those isn't reachable from the public internet, Lunar needs a private path to it.

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 Lunar reaching **out** to your systems. Reaching the Lunar hub **in** from your CI and browsers is a separate, independent setup, covered in [PrivateLink to Your Hub](/install/lunar-hub/dedicated/privatelink-inbound.md), which is the recommended way to do it. See the [Overview](/install/lunar-hub/dedicated/overview.md#how-you-connect) for the alternatives.
{% endhint %}

{% hint style="warning" %}
**A self-managed Git platform needs both directions.** If your GitHub Enterprise Server or GitLab instance only resolves inside your network, it is a target for this page: Lunar has to reach it to read repositories and post results. Separately, your instance has to deliver **webhooks** to the hub, which this page does not cover. If it can reach the internet, it delivers to your install's public webhook listener; if it cannot, it needs a private path in — see [Decide the webhook path](/install/lunar-hub/dedicated/privatelink-inbound.md#decide-the-webhook-path).
{% endhint %}

## Do you need this?

Each of these can rule the work out, so answer them before you build anything.

**1. Is the target actually unreachable from the internet?** Your Lunar install has a single, stable outbound IP address. If your service is internet-facing and you can allowlist one IP, that's far less work than everything below. Ask Earthly for the address.

**2. Can you publish TXT records on a publicly-resolvable domain?** AWS requires proof of domain ownership before it will attach a hostname to an endpoint service. Verification is scoped to the *parent* domain, so a name under `example.com` works for any subdomain. If your internal hostname sits under a suffix you can't prove ownership of (something like `.corp` or `.internal` with no public zone), tell Earthly, because there's a fallback for that case (Earthly overrides DNS inside the install instead) and it changes what you build here.

**3. Can the service present a publicly-trusted TLS certificate?** Lunar connects with hostname verification on, so the certificate must be valid for the hostname in question 2 *and* trusted by Lunar's workloads.

If you control the public zone from question 2, you can almost certainly get a public certificate for this name: DNS-01 validation only needs a TXT record, so the name never has to resolve publicly. Terminating TLS at the load balancer with an ACM certificate works the same way.

If the service can only present a certificate from your **internal** CA, tell Earthly. We'll need your CA's root and intermediate **certificates** (the public ones, never a private key) to add to the trust store Lunar's workloads use. Flag it too if your CA's revocation endpoints (CRL or OCSP) are only reachable inside your network, since Lunar won't be able to reach them.

**4. How many distinct hostnames does Lunar need to reach?** Not systems; **hostnames**. Remember to count your Git platform if it's self-managed and internal. An endpoint service can carry exactly one private DNS name, though that name may be a wildcard (`*.internal.example.com`). Three hostnames means three endpoint services, a gateway doing path-based routing under a single hostname, or a wildcard name in front of a gateway that routes by hostname.

## How it works

You publish your internal service as an **endpoint service**. Earthly creates a matching **interface endpoint** inside the Lunar install's VPC. Lunar then reaches your service by its normal hostname, over a private AWS path.

```mermaid
flowchart LR
  subgraph Dedicated["Lunar Dedicated account (Earthly-managed)"]
    W["Lunar Hub · collector<br/>and policy workloads"]
    EP["Interface VPC endpoint<br/>(Earthly creates)"]
    W --> EP
  end
  subgraph Customer["Your AWS account"]
    ES["Endpoint service<br/>(you create)"]
    NLB["Internal Network Load Balancer<br/>(you create)"]
    SVC["Your internal service"]
    ES --> NLB --> SVC
  end
  EP -- "AWS PrivateLink — one-way, Lunar initiates" --> ES
```

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

* **The connection is one-way.** Lunar initiates every session. Nothing in your account can initiate a connection back through it.
* **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.
* **Lunar can reach only the service you publish.** Not the VPC it lives in, not anything else in your account.

{% hint style="warning" %}
**PrivateLink is not authentication.** It's a private network path, nothing more. TLS and your service's normal credentials remain fully in force, exactly as if Lunar were calling over the internet.
{% endhint %}

## Checklist

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

1. Internal NLB in front of the service, targets healthy.
2. Endpoint service backed by that NLB.
3. Allowed principal: `arn:aws:iam::<DEDICATED_ACCOUNT_ID>:root` (Earthly provides the ID).
4. Acceptance: **automatic**. See [step 4](#step-4-choose-automatic-or-manual-acceptance) before choosing manual; it requires extra steps.
5. Private DNS name: your service's real TLS hostname. Publish the TXT record, wait for `verified`.
6. Send Earthly the values in [What to send back](#step-6-what-to-send-back).

Three details that are easy to miss:

* **Each endpoint service gets its own TXT verification name and value**, so an already-verified domain does not carry over from another service.
* The private DNS name must be the hostname on your service's TLS certificate, or Lunar's requests will fail hostname verification. If that certificate comes from an internal CA rather than a public one, Earthly needs your CA certificates too.
* **Availability zones must be exchanged as zone IDs, not zone names** (see [step 6](#step-6-what-to-send-back)).

## What you need from Earthly first

You need two values from Earthly. The **region** is fixed per install and agreed up front; whoever forwarded you this page will have it. Everything you build here must be in the same region as the install.

The other is the **dedicated account ID**, used for the allowed principal in [step 3](#step-3-allow-earthlys-dedicated-account). It's needed at just that one step, so if you don't have it yet, start building anyway and slot it in when it arrives.

## Step 1 — Put the service behind an internal Network Load Balancer

An endpoint service can only be backed by a Network Load Balancer, so the service needs one in front of it.

* Scheme: **internal**. PrivateLink reaches an internal NLB fine, and an internet-facing one would expose the service publicly, defeating the point.
* Add a TCP listener on the port Lunar will use: **443** unless you tell Earthly otherwise.
* Register your service as a target and **confirm the targets are healthy** before continuing.

An unhealthy target group produces a connection that looks fine on both sides and still refuses traffic. The endpoint will happily reach `Available` with a completely broken backend.

Note the availability zones your NLB is in, as **zone IDs** (`euw2-az1`) rather than zone names (`eu-west-2a`), because names don't mean the same thing in Earthly's account. Endpoint services are only reachable in the zones their load balancer covers, and Earthly's endpoint has to land in a zone you support. You'll send these in [step 6](#step-6-what-to-send-back).

## Step 2 — Create the endpoint service

In the AWS console, go to **VPC → Endpoint services → Create endpoint service**. Not the load balancer screens; this is its own top-level resource.

* Load balancer type: **Network**
* Select the NLB from step 1
* **Require acceptance for endpoint**: the console asks now; automatic acceptance (box unchecked) is recommended

{% hint style="warning" %}
Requiring manual acceptance delays the connection and forces a two-step setup on Earthly's side, so read [step 4](#step-4-choose-automatic-or-manual-acceptance) before checking that box.
{% endhint %}

AWS generates a service name that looks like:

```
com.amazonaws.vpce.<region>.vpce-svc-0123456789abcdef0
```

That string is the value Earthly actually uses to connect. Keep it.

## Step 3 — Allow Earthly's dedicated account

On the endpoint service, open **Allow principals** and add:

```
arn:aws:iam::<DEDICATED_ACCOUNT_ID>:root
```

This permits principals in that one account to *request* a connection to this one service. It grants no IAM credentials, no assumable role, and no access to anything else in your account. A request from any other account is rejected by AWS before it ever appears in your console.

{% hint style="warning" %}
**You may see a second, similar-looking ARN during Lunar onboarding.** Setting up the install also involves an IAM role that *your* side assumes to deposit secrets. That role trusts your account and uses role assumption with an ExternalId (covered in [Setup](/install/lunar-hub/dedicated/setup.md#step-2-identify-your-depositor-account)). This allowlist entry is not that: it involves no role assumption at all, only permission to request a PrivateLink connection.
{% endhint %}

## Step 4 — Choose automatic or manual acceptance

**Automatic acceptance is strongly recommended.** Earthly's endpoint connects as soon as it's created, and private DNS is configured in the same step. Your access control is the allowlist from step 3, which you've already set; automatic acceptance widens nothing.

**Manual acceptance** adds a human approval for each connection. Someone on your side approves it at **VPC → Endpoint services → your service → Endpoint connections**, selects the endpoint ID Earthly sends, and chooses **Accept endpoint connection request**. The CLI equivalent is `aws ec2 accept-vpc-endpoint-connections`. It's a single approval, with nothing to configure.

{% hint style="warning" %}
**Manual acceptance costs more than one click.** AWS will not enable private DNS on an endpoint whose connection hasn't been accepted yet. So setup on Earthly's side happens in two steps: create the endpoint, wait for your approval, then make a second change to turn on private DNS. Until that second change lands, your hostname doesn't resolve inside the install.

Choose manual only if your process genuinely requires a named approver per connection. If it does, tell Earthly at [step 6](#step-6-what-to-send-back) so the second step is planned rather than discovered.
{% endhint %}

## Step 5 — Associate and verify the private DNS name

This step is what lets Lunar call your service by its real hostname, with working TLS. Skipping it means Lunar can only reach an AWS-generated hostname that your certificate won't match.

**5a. Associate the name.** On the endpoint service, set **private DNS name** to your service's canonical hostname: the name on its TLS certificate, for example `service.internal.example.com`.

Use a fully-qualified name. A bare single-label name (just `gitlab`) will not work.

**5b. Publish the verification record.** AWS returns a TXT record name and value. Find them under **Domain verification name** and **Domain verification value** on the service's **Details** tab, or:

```bash
aws ec2 describe-vpc-endpoint-service-configurations \
  --service-ids vpce-svc-0123456789abcdef0 \
  --query 'ServiceConfigurations[*].PrivateDnsNameConfiguration'
```

Create that TXT record in the **public** DNS zone for the domain:

| Name                            | Type | Value                   |
| ------------------------------- | ---- | ----------------------- |
| `_a1b2c3d4e5f6g7h8.example.com` | TXT  | `vpce:AbCdEf0123456789` |

{% hint style="info" %}
**An already-verified domain does not carry over.** Each endpoint service gets its own verification name and value, so if you've published private DNS names for other services on this domain you still need a new TXT record for this one.
{% endhint %}

**5c. Wait for verification.** Check **Domain verification status** on the Details tab. If it stays pending, use **Actions → Verify domain ownership for private DNS name** to retry. DNS changes can take up to 48 hours to propagate, though it's usually much faster.

**Do not tell Earthly you're ready until the status reads `verified`.** Earthly's endpoint is created with private DNS enabled, and that request is rejected against a service whose name isn't verified yet.

## Prefer Terraform?

Steps 2–5 are one resource. This is the recommended minimal shape if your networking is Terraform-managed:

```hcl
resource "aws_vpc_endpoint_service" "lunar" {
  acceptance_required        = false
  network_load_balancer_arns = [aws_lb.internal.arn]
  private_dns_name           = "service.internal.example.com"

  allowed_principals = [
    "arn:aws:iam::<DEDICATED_ACCOUNT_ID>:root",
  ]
}
```

The TXT record from step 5 is still published separately, through your DNS provider or an `aws_route53_record` if the zone lives in Route53.

## Step 6 — What to send back

Send Earthly these six values:

| Value                               | Example                                                   |
| ----------------------------------- | --------------------------------------------------------- |
| **Endpoint service name**           | `com.amazonaws.vpce.eu-west-2.vpce-svc-0123456789abcdef0` |
| **Verified private DNS name**       | `service.internal.example.com`                            |
| **TCP port**                        | `443`                                                     |
| **Region**                          | `eu-west-2`                                               |
| **Acceptance**                      | automatic, or manual                                      |
| **Supported availability zone IDs** | `euw2-az1`, `euw2-az2`                                    |

If you flagged an internal CA in question 3, include your CA certificates as well. Earthly needs nothing further to create its side of the connection.

{% hint style="warning" %}
**Send zone IDs, not zone names.** AWS maps zone *names* like `eu-west-2a` to different physical zones in different accounts, so a name is meaningless across an account boundary. Zone **IDs** like `euw2-az1` are stable everywhere. Get them with:

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

Send the IDs for the zones your load balancer covers.
{% endhint %}

## What happens next

From your six values, Earthly creates the interface endpoint, plus a security group that allows only the port you specified, and only from Lunar's workloads.

If you chose manual acceptance, Earthly sends you the endpoint ID at this point and waits for your approval, then makes a second change to enable private DNS.

An endpoint reaching `Available` proves less than it sounds: it means the PrivateLink connection was established, not that anything behind it works. So Earthly checks two things from a real Lunar workload:

* **The hostname resolves to the endpoint's private addresses.** This is what proves traffic is taking the private path, rather than resolving to a public address and quietly bypassing the endpoint.
* **TLS completes with hostname verification enabled.** A successful handshake exercises the whole path, so it also confirms your load balancer targets are healthy and your certificate covers the name.

Earthly then runs a collection against your service and confirms the data lands. It's the same end-to-end check every install gets, private or not.

## How DNS resolves

Worth understanding if you're reviewing this, because nothing about it is visible in your account.

When Earthly creates the interface endpoint, AWS reads the verified private DNS name from your endpoint service and creates a hidden, AWS-managed private hosted zone associated with the Lunar VPC. That zone maps your hostname to the endpoint's network interfaces.

The consequence is split-horizon resolution: inside the Lunar install, `service.internal.example.com` resolves to the private endpoint. Everywhere else in the world, it resolves however it always did. Lunar's configuration contains an ordinary hostname and an ordinary HTTPS URL; nothing in Lunar knows PrivateLink is involved.

{% hint style="warning" %}
**Don't remove the TXT record afterwards.** If domain verification later lapses, AWS denies *new* connection requests while existing connections keep working. The install carries on fine and the problem surfaces only when the endpoint is next recreated, potentially months later during an unrelated maintenance window.
{% endhint %}

## Troubleshooting

**Earthly's endpoint is stuck in `PendingAcceptance`.** Manual acceptance is enabled and nobody has approved the connection. Approve it under **Endpoint connections** on your service.

**Earthly reports the connection request failed outright.** Usually, the allowed principal is incorrect. Check the account ID in step 3 matches the one Earthly gave you exactly. An unlisted account can't see the service at all, so the failure happens on Earthly's side and never reaches your console.

**Endpoint creation fails with a private DNS error.** An error saying private DNS requires an accepted connection means manual acceptance is on. Approve the pending connection; Earthly completes the second step. See [step 4](#step-4-choose-automatic-or-manual-acceptance).

**Endpoint creation fails on availability zones.** Earthly's endpoint has to be placed in zones your service supports. Send the zone **IDs** from step 6 (zone names don't translate across accounts) and Earthly will constrain placement.

**The endpoint is `Available` but requests fail or hang.** Check your NLB target group health first. Endpoint state reflects the PrivateLink connection, not your backend.

**Requests connect but TLS fails.** The hostname Lunar is calling isn't covered by your certificate's subject alternative names. The private DNS name in step 5 must be a name the certificate actually presents.

**Domain verification won't complete.** Check whether your DNS provider allows underscores in record names; if not, you can omit the AWS-supplied prefix and use the bare domain. Also check that your provider hasn't appended the domain a second time, or lowercased the value; AWS matches it exactly.


---

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