> ## Documentation Index
> Fetch the complete documentation index at: https://docs.heylua.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Egress addresses and delivery policy

> The IP addresses log drain deliveries leave Lua from, how that list changes, and the policy every delivery follows — HTTPS only, public hosts only, redirects refused, a ten-second attempt, and the headers that identify a batch

Deliveries leave from Lua's own network, not from your agent's sandbox and not from your own infrastructure. If your receiver sits behind an IP allow-list, these are the addresses to admit.

Everything on this page is about the **outbound** side — what Lua sends and where it sends it from. What arrives in the body is on [Event schema](/drains/event-schema); what happens when a delivery fails is on [Delivery guarantees](/drains/delivery-guarantees).

## The addresses

| Environment    | Address            | Region                | Last changed |
| -------------- | ------------------ | --------------------- | ------------ |
| **Production** | `34.255.23.103/32` | `eu-west-1` (Ireland) | 2026-05-15   |
| **Staging**    | `52.19.25.127/32`  | `eu-west-1` (Ireland) | 2026-05-15   |

Both addresses are single hosts, not ranges — a `/32` each. Neither has changed since the date above.

**Allow both.** Production carries every delivery to a customer destination, for agents in the `production` and `sandbox` environments alike: the environment a record came from decides what is copied, not where it is copied from. Staging is Lua's own pre-production platform. You will normally only ever see the production address; admit staging as well so that a Lua-side test against your endpoint is not silently blocked.

<Note>
  There is no per-customer address and no regional egress. Every organization's deliveries leave from the same addresses, so seeing one of them in your logs identifies Lua, not your account. Use [`X-Lua-Drain-Id`](#headers-on-every-delivery) — or a signature — to tell your own drains apart on a shared receiver.
</Note>

## The machine-readable list

The same list is served as JSON, so an allow-list can be refreshed from CI rather than from this page:

```text theme={null}
https://docs.heylua.ai/drains/egress.json
```

That URL serves [`egress.json`](/drains/egress.json), which you can fetch, diff, and feed straight into a firewall rule.

```json egress.json theme={null}
{
  "version": 1,
  "updatedAt": "2026-09-22T00:00:00Z",
  "documentation": "https://docs.heylua.ai/drains/egress",
  "userAgent": "LuaDrain/1.0 (+https://docs.heylua.ai/drains)",
  "addresses": [
    {
      "environment": "production",
      "address": "34.255.23.103",
      "cidr": "34.255.23.103/32",
      "region": "eu-west-1",
      "lastChanged": "2026-05-15"
    },
    {
      "environment": "staging",
      "address": "52.19.25.127",
      "cidr": "52.19.25.127/32",
      "region": "eu-west-1",
      "lastChanged": "2026-05-15"
    }
  ]
}
```

| Field                     | Meaning                                                                                          |
| ------------------------- | ------------------------------------------------------------------------------------------------ |
| `version`                 | Increments whenever the set of addresses changes. Nothing else bumps it — a wording fix does not |
| `updatedAt`               | When the file was last written, in UTC                                                           |
| `addresses[].cidr`        | The address in the form a firewall rule wants                                                    |
| `addresses[].lastChanged` | When that entry last changed                                                                     |

Pull the production addresses in one line:

```bash theme={null}
curl -fsS https://docs.heylua.ai/drains/egress.json \
  | jq -r '.addresses[] | select(.environment == "production") | .cidr'
```

```text Output theme={null}
34.255.23.103/32
```

A guard that fails your build when the list moves under you:

```bash theme={null}
EXPECTED=1
ACTUAL=$(curl -fsS https://docs.heylua.ai/drains/egress.json | jq -r '.version')
[ "$ACTUAL" = "$EXPECTED" ] || {
  echo "Lua egress list is now version $ACTUAL — review https://docs.heylua.ai/drains/egress" >&2
  exit 1
}
```

## How the list changes

It changes rarely, and never quietly.

**An address is published before it is used, never after.** A new address appears on this page and in `egress.json` — with a higher `version` — while deliveries are still leaving from the current one. Only once it is published does traffic start arriving from it. The reverse order, rotating first and telling you afterwards, is the failure this policy exists to prevent.

A retired address is removed from the list only after deliveries have stopped leaving from it, so an allow-list that still holds an old entry is stale, never broken.

Changes are announced in the [changelog](/changelog) as well as here. If you want to be told rather than to poll, watch the `version` field with the guard above, or write to [support@heylua.ai](mailto:support@heylua.ai) to be added to the notification list for egress changes.

<Warning>
  Allow-list by IP address, not by hostname. `docs.heylua.ai` is a documentation site behind a CDN and its addresses have nothing to do with where deliveries come from.
</Warning>

## Delivery policy

Every delivery obeys the following, on every attempt, with no per-drain override.

### The destination must be HTTPS

Only `https://` endpoints are accepted. A plain `http://` endpoint is refused when the drain is created and refused again on every send — there is no flag, no environment, and no support exception that relaxes it.

Deliveries go to TCP 443 unless your endpoint URL names another port.

### The destination must be a public host

Before each attempt, the endpoint's hostname is resolved and **every** A and AAAA record is checked. If any one of them lands in a private, loopback, link-local, unique-local, or cloud metadata range, the delivery is refused.

The check runs at send time, every time, and its result is never cached — a name that answered a public address when the drain was created and answers `169.254.169.254` an hour later is refused at that moment, not trusted on the strength of the earlier answer.

A refusal is **terminal**: it is recorded with error class `ssrf` and is not retried, because the same destination would be refused again. A hostname that fails to resolve at all is a different thing — error class `dns`, and it does retry.

<Note>
  This is why a receiver on a private network needs something public in front of it: a load balancer, a tunnel, or a relay. Pointing a drain at `10.0.0.5` or at a name that resolves to one will never deliver, however the network is arranged on your side.
</Note>

### Redirects are never followed

A `3xx` response is a terminal failure, recorded with error class `redirect`. It is not a transient error and it is not retried.

A redirect is how a destination that passed the public-host check hands the request to one that never did, so following it would make the check meaningless. Point the drain at the final URL instead — including the trailing slash, if your receiver redirects to add one.

### Each attempt gets ten seconds

One attempt, connection through response, is bounded at **10 seconds**. A slower response is abandoned and recorded with error class `timeout`, and the batch is retried.

At most **4 requests** are in flight to any one drain at a time, so a slow destination occupies four delivery slots and never the whole pool.

### Retry and backoff

A retryable failure — a network error, a DNS failure, a timeout, or a retried status code — puts the batch back in the queue with a delay drawn uniformly from zero up to a ceiling that doubles per attempt and stops at 60 seconds.

| Consecutive failures | Delay drawn from |
| -------------------- | ---------------- |
| 1                    | 0–2 s            |
| 2                    | 0–4 s            |
| 3                    | 0–8 s            |
| 4                    | 0–16 s           |
| 5                    | 0–32 s           |
| 6 and after          | 0–60 s           |

Because the delay is drawn from the whole interval rather than sitting at its top, a destination coming back up is not hit by every drain's backlog in the same instant. A `Retry-After` header — delta-seconds or an HTTP date — overrides the draw and is honoured exactly, clamped at one hour.

Records stop being retried after **6 hours** of waiting. An endpoint that returns from a long outage receives the last six hours, not a six-day flood. [Delivery guarantees](/drains/delivery-guarantees#retry-and-backoff) has the full status-code table and the health states.

### Headers on every delivery

Every delivery, to every destination type, identifies itself:

```http theme={null}
User-Agent: LuaDrain/1.0 (+https://docs.heylua.ai/drains)
```

That value is fixed. A drain's custom headers cannot overwrite it, and neither can they overwrite `Content-Type`, `Content-Encoding`, `Content-Length`, `Host`, or any header beginning `X-Lua-`.

Bodies are always gzipped, so every delivery also carries `Content-Encoding: gzip`.

Deliveries to a [generic HTTPS](/drains/generic-https) drain carry the Lua protocol headers as well:

| Header            | Value                                                                    |
| ----------------- | ------------------------------------------------------------------------ |
| `X-Lua-Drain-Id`  | The drain the batch belongs to. Route by this on a shared receiver       |
| `X-Lua-Batch-Id`  | A ULID identifying this one HTTP request. Quote it in a support request  |
| `X-Lua-Schema`    | The schema URL the body conforms to                                      |
| `X-Lua-Signature` | `t=<unix seconds>,v1=<hex>` — present when the drain has signing enabled |

`X-Lua-Signature` is an HMAC-SHA256 over the **uncompressed** body, computed before the body is gzipped, with the timestamp `t` in whole Unix seconds. A second `v1=` value appears for the overlap window after a secret rotation, so verify against any of them. The signature is only valid within five minutes of `t`. [Verify signatures](/drains/verify-signatures) has a worked verifier.

<Warning>
  Deliveries to OpenTelemetry, Datadog, and Better Stack destinations carry the `User-Agent` and the vendor's own auth header, but **no** `X-Lua-Signature`: a vendor intake would reject unknown headers or ignore them, and the signature has no verifier on the other side. If you need signed deliveries, use a generic HTTPS drain.
</Warning>

## Allow-list examples

Admit both addresses on TCP 443 inbound to your receiver.

<Tabs>
  <Tab title="AWS security group">
    ```bash theme={null}
    SG=sg-0123456789abcdef0

    for CIDR in $(curl -fsS https://docs.heylua.ai/drains/egress.json | jq -r '.addresses[].cidr'); do
      aws ec2 authorize-security-group-ingress \
        --group-id "$SG" \
        --ip-permissions "IpProtocol=tcp,FromPort=443,ToPort=443,IpRanges=[{CidrIp=$CIDR,Description='Lua log drains'}]"
    done
    ```
  </Tab>

  <Tab title="nftables">
    ```bash theme={null}
    nft add rule inet filter input tcp dport 443 ip saddr \
      { 34.255.23.103, 52.19.25.127 } accept
    ```
  </Tab>

  <Tab title="iptables">
    ```bash theme={null}
    iptables -A INPUT -p tcp --dport 443 -s 34.255.23.103/32 -j ACCEPT
    iptables -A INPUT -p tcp --dport 443 -s 52.19.25.127/32 -j ACCEPT
    ```
  </Tab>

  <Tab title="nginx">
    ```nginx theme={null}
    location /lua-drain {
      allow 34.255.23.103;
      allow 52.19.25.127;
      deny  all;

      proxy_pass http://drain_receiver;
    }
    ```
  </Tab>

  <Tab title="Cloudflare WAF">
    Create a custom rule scoped to your receiver's path:

    ```text theme={null}
    (http.request.uri.path eq "/lua-drain"
      and not ip.src in {34.255.23.103 52.19.25.127})
    ```

    with the action **Block**. Leave the rule above any rate-limiting rule, so a burst of retries is refused on identity rather than counted against a limit.
  </Tab>
</Tabs>

<Tip>
  An allow-list is not authentication. Any host that can reach your receiver from one of these addresses looks the same to an IP rule, and the addresses are public. Keep the [signature check](/drains/verify-signatures) — or a shared secret header — as the thing that actually proves a batch came from your drain, and treat the allow-list as defence in depth.
</Tip>

## If deliveries are not arriving

| Symptom                                            | Likely cause                                                                                                  |
| -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| `timeout` on every attempt, nothing in your logs   | The allow-list has not been applied, or is applied at a layer your logs do not see                            |
| `ssrf` on every attempt                            | Your endpoint's name resolves to a private address, at least on one record                                    |
| `redirect` on every attempt                        | Your receiver redirects — to add a trailing slash, or from an apex to `www`. Point the drain at the final URL |
| Deliveries arrive from an address not on this page | Not a Lua drain. Check `User-Agent` and the signature before trusting it                                      |

```bash theme={null}
lua drains deliveries drn_9f31a7c04b2e615d8a03cc71 --limit 5
```

## Next steps

<Columns cols={2}>
  <Card title="Verify signatures" href="/drains/verify-signatures">Prove a batch came from Lua, with a worked verifier.</Card>
  <Card title="Delivery guarantees" href="/drains/delivery-guarantees">At-least-once, the status-code table, and the health states.</Card>
  <Card title="Generic HTTPS" href="/drains/generic-https">Your own receiver, signed, with a copy-paste example.</Card>
  <Card title="lua drains" href="/reference/cli/drains">Every verb, flag, and `--json` shape.</Card>
</Columns>
