# Behind your own proxy

Put your own load balancer, reverse proxy or CDN in front of GoRunner.

Your proxy holds the certificate and passes every request to GoRunner on a local port. No Caddy runs. On Kubernetes, your ingress plays this part. See [The ingress](https://docs.gorunner.io/self-host/install-kubernetes#the-ingress).

## 1. Run the setup in own-proxy mode

In the install folder, give the address people open and where your proxy reaches GoRunner.

- `--behind-proxy`: The https address your proxy serves.
- `--app-port`: Where your proxy connects. `127.0.0.1:8080`, the default, suits a proxy on this server. Never an address the internet can reach: this port is plain HTTP.
- `--trusted-proxies`: Required for a proxy on another machine: its own address or subnet.

Without flags, `./setup.sh` asks: under “The way in”, choose **2**. A running installation first asks “Change any of them?”: answer **y**.

```bash
# a proxy on this server
./setup.sh \
  --behind-proxy https://automations.example.com \
  --app-port 127.0.0.1:8080
```

```bash
# a load balancer on another machine
./setup.sh \
  --behind-proxy https://automations.example.com \
  --app-port 10.0.1.5:8080 \
  --trusted-proxies 10.0.1.0/24
```

## 2. Point your proxy at GoRunner

Whatever the proxy, it must:

- **Pass every path**: `/`, `/api`, `/embed`, `/embed.js`, `/embed-sdk`, `/mcp`, `/oauth` and `/.well-known`, to one place. `/api` holds webhooks and hosted forms too; `/mcp`, `/oauth` and `/.well-known/oauth-…` connect AI apps.
- **Name the caller**: Keep `Host`, and **set** `X-Forwarded-For` to the connecting address, replacing what the caller sent.
- **Stream**: No response buffering, and at least 60 seconds of quiet; an hour is safe. GoRunner sends a keep-alive every 25 seconds.
- **Take 6 MiB**: GoRunner takes request bodies up to 5 MiB. nginx refuses over 1 MB unless raised.
- **Add no security headers**: No `X-Frame-Options` or `Content-Security-Policy`. `Strict-Transport-Security` is yours to add.
- **Compress nothing it must hold back**: GoRunner compresses the dashboard’s files itself. Compressing API answers is fine, but never by buffering a stream.

A health check can ask `GET /api/ready`: `200` when GoRunner reaches its database and Redis. See [Health checks](https://docs.gorunner.io/self-host/health-checks).

**An AWS Application Load Balancer?**

- **Target group**: HTTP, the `--app-port` port on the server’s private address, health check `/api/ready`, success code `200`.
- **Listener**: HTTPS 443 with your certificate from ACM.
- **Idle timeout**: The default 60 seconds works. Never lower it.
- **Trusted ranges**: The subnets the load balancer runs in, such as `10.0.1.0/24 10.0.2.0/24`.

It streams, takes any body size and adds `X-Forwarded-For` itself. Open the server’s port to the load balancer only.

**nginx**

```
upstream gorunner {
    server 127.0.0.1:8080;
    keepalive 16;
}

server {
    listen 443 ssl;
    http2 on;
    server_name automations.example.com;
    ssl_certificate     /etc/ssl/automations.example.com.crt;
    ssl_certificate_key /etc/ssl/automations.example.com.key;

    client_max_body_size 6m;

    location / {
        proxy_pass http://gorunner;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $remote_addr;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_buffering off;
        proxy_read_timeout 1h;
    }
}
```

**HAProxy**

```
frontend https
    bind :443 ssl crt /etc/haproxy/certs/automations.example.com.pem
    mode http
    http-request set-header X-Forwarded-For %[src]
    http-request set-header X-Forwarded-Proto https
    default_backend gorunner

backend gorunner
    mode http
    option httpchk GET /api/ready
    http-check expect status 200
    timeout server 1h
    server app 127.0.0.1:8080 check
```

**Traefik**

```
http:
  routers:
    gorunner:
      rule: Host(`automations.example.com`)
      entryPoints: [websecure]
      service: gorunner
      tls:
        certResolver: letsencrypt
  services:
    gorunner:
      loadBalancer:
        servers:
          - url: http://127.0.0.1:8080
        healthCheck:
          path: /api/ready
          interval: 10s
```

Behind a CDN, let nginx learn the caller first: `set_real_ip_from <its range>;` and `real_ip_header X-Forwarded-For;`. Traefik replaces forwarding headers a caller sent, unless the caller is in its entry point’s `forwardedHeaders.trustedIPs`.

## 3. Check it

1. Open your address. The sign-in page appears, with your proxy’s certificate.
2. Run a workflow from the editor. Its steps light up one by one: streaming works.
3. Open **Platform › Audit log**. Your sign-in shows your computer’s address, not the proxy’s.

```bash
curl -sS http://127.0.0.1:8080/api/ready
```

## The address ranges to trust

Sign-in limits, an embed key’s address list and the audit log all need the caller’s real address. GoRunner reads `X-Forwarded-For` only from a connection it trusts, from the right, and takes the first address it doesn’t trust. It then drops `Forwarded` and `True-Client-IP`, and overwrites `X-Real-IP` and `X-Forwarded-For` with that address.

| Your proxy | Trust |
| --- | --- |
| On this server, `--app-port` on `127.0.0.1` | Nothing to give. The setup writes `gateway`, which only programs on this server can use. |
| On another machine | Its own address or subnet. Required. |
| A CDN in front of your proxy | Let your proxy learn the caller from it. Or add its range: on this server, it adds to `gateway`. |

Give ranges separated by spaces or commas. The setup refuses `private_ranges`: it would trust every container on the server, workflow steps included. A range covering every address stops GoRunner from starting.

> **Warning** A trusted range is believed about who is calling. Trust one that ordinary callers can reach, and they get past the sign-in limits and an API key’s address list, and into the audit log under another address.

**A proxy in front of automatic HTTPS?**

Name it with `--trusted-proxies`; on this server, with Caddy’s ports on `127.0.0.1`, that is `gateway`. Caddy never trusts a range holding one of Docker’s networks, so `private_ranges` is refused there too. Name an IPv6 proxy by its exact address.

## For Embed

Embed needs sign-in on, a license that includes Embedding, and one https address: an https page can’t frame an http installation. Pass `/embed/` untouched: no cache, no framing header of your own. Without the license or sign-in, `/embed/` answers `404`.

- `/embed/`: The embed document, only to your allowed websites. Its `frame-ancestors` names only the page it was opened for, with `Cache-Control: no-store`.
- `/embed.js`: The SDK, always the current version: `no-cache` with an `ETag`.
- `/embed-sdk/<version>/embed.js`: One version, never changed, cached for a year. Pin it with Subresource Integrity.
- `/api/embed/v1/…`: The embed API and the frame’s sign-in.

GoRunner decides on every load, from the installation’s and each customer’s allowed websites; they never appear in a header. `/embed.html` is always `404`. The SDK’s files answer `Access-Control-Allow-Origin: *` and `Cross-Origin-Resource-Policy: cross-origin`. The rest of the dashboard sends `X-Frame-Options: SAMEORIGIN`, the AI app consent screen `DENY`. See [Set up Embed](https://docs.gorunner.io/embed/set-up).

**Set up behind a proxy with v2026.10.0 or v2026.10.1?**

It keeps GoRunner’s Caddy between your proxy and the app, on the same port. To connect directly, run `./setup.sh`, answer **y** to “Change any of them?”, and choose **2** under “The way in”. Then point your proxy at the address it gives.

A proxy that asks `GET /api/embed/frame-policy` itself must add `?parent=<the embed document’s parent>` from v2026.10.4. Pointing it at the app as above needs none of that.

## Related

- [A company proxy](https://docs.gorunner.io/self-host/company-proxy): For traffic leaving GoRunner · 5 min
- [Health checks](https://docs.gorunner.io/self-host/health-checks): /api/ready, and who sees the details
- [Troubleshooting](https://docs.gorunner.io/self-host/troubleshooting): The address doesn’t open, and more

Source: https://docs.gorunner.io/self-host/own-proxy
