---
title: Reverse proxy
description: Serve the web UI under a sub-path behind a reverse proxy. Covers TLS, forwarded headers, the WebSocket path and cross-origin settings.
---

The `pipedash-web` server speaks plain HTTP and can run behind a reverse proxy that adds TLS or a sub-path. This page covers the settings on both sides.

## Serve under a sub-path

`server.base_path` in `config.toml` or the variable `PIPEDASH_BASE_PATH` sets the path of the web UI, for example `/pipedash`. The variable wins over the file. An empty value serves the UI at `/`.

```toml
[server]
base_path = "/pipedash"
```

Pipedash normalizes the value. It trims blanks and slashes, then adds a leading and a trailing slash, so `pipedash`, `/pipedash` and `/pipedash/` all give `/pipedash/`. A segment may use letters, digits, `-`, `.`, `_` and `~`. Any other character, an empty segment, `.` or `..` stops the server at startup with the error `Invalid base path`.

The server writes the value into the `<base href>` tag of the page it serves. Assets, API calls, the WebSocket and router links of the UI then resolve under the path.

The server itself still answers on `/`. It does not know the prefix in incoming requests.

<Callout type="warn">
The proxy must strip the prefix before it forwards a request. This includes the REST routes under `/api/v1` and the WebSocket at `/api/v1/ws`. A request that keeps the prefix finds no route and no asset.
</Callout>

Through the proxy of the example below, the public addresses look like this.

| Public address | Forwarded to the server as |
|---|---|
| `https://tools.example.com/pipedash/` | `/` |
| `https://tools.example.com/pipedash/api/v1/health` | `/api/v1/health` |
| `https://tools.example.com/pipedash/api/v1/ws` | `/api/v1/ws` |

The UI also renders at the bare address `https://tools.example.com/pipedash`, as long as the proxy forwards it to `/`.

## TLS termination

Pipedash has no TLS listener. The proxy handles TLS and forwards plain HTTP to `PIPEDASH_BIND_ADDR`. Bind the server to a loopback address, such as the default `127.0.0.1:8080`, when the proxy runs on the same host. A container needs `0.0.0.0:8080`, as the [Docker](/docs/deployment/docker) page explains.

## Forwarded headers

The server uses two request headers from the proxy.

- **`Host`:** the cross-origin check compares it with the `Origin` header of the browser.
- **`X-Forwarded-Proto`:** the check reads the first value of this header as the public scheme. Without the header the scheme is `http`.

The check applies to `POST`, `PUT`, `PATCH`, `DELETE` and WebSocket upgrade requests. A request that carries `Sec-Fetch-Site` is allowed when the value is `same-origin` or `none`. A request without that header falls back to comparing `Origin` with `Host` and the scheme. The server strips the default port of the scheme from `Host` before it compares. A mismatch returns `403`.

A proxy that rewrites `Host` to its upstream address, or that sends no `X-Forwarded-Proto` on an HTTPS site, makes this fallback fail. Pass the original `Host` value, including a non-default port, and the scheme of the public address.

Requests without an `Origin` header, such as `curl` and scripts, are not affected.

## nginx example

Apart from `listen` and `server_name`, every directive below maps to the server behavior above.

```nginx
server {
    listen 443 ssl;
    server_name tools.example.com;

    ssl_certificate     /etc/ssl/tools.example.com.crt;
    ssl_certificate_key /etc/ssl/tools.example.com.key;

    location /pipedash/api/v1/ws {
        proxy_pass http://127.0.0.1:8080/api/v1/ws;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $http_host;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    location /pipedash/ {
        proxy_pass http://127.0.0.1:8080/;
        proxy_set_header Host $http_host;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}
```

| Directive | Reason |
|---|---|
| `ssl_certificate` and `ssl_certificate_key` | The server has no TLS, so the proxy holds the certificate. |
| `proxy_pass` with a trailing `/` | The URI part replaces the matched `/pipedash/` prefix, which the server needs stripped. |
| `proxy_pass` in the WebSocket block | It strips the prefix from `/api/v1/ws` in the same way. |
| `proxy_http_version 1.1`, `Upgrade` and `Connection` | The server accepts a WebSocket only after an upgrade request, and it detects it from the `Upgrade` header. |
| `Host $http_host` | The server compares the original host, with its port, against `Origin`. |
| `X-Forwarded-Proto $scheme` | The server derives the public scheme from this header. |

Serve the UI at the root of a host instead by setting no base path and using one `location /` with `proxy_pass http://127.0.0.1:8080;`.

## Frontend on another origin

A browser that loads the UI from another origin than the API gets `403` on every write and on the WebSocket. The server's `Origin` check rejects them. To allow that setup, set `PIPEDASH_CORS_ALLOW_ALL=true` or `server.cors_allow_all = true`.

This setting allows any origin, method and header and turns the cross-origin write check off. When the API is open to the network, set `PIPEDASH_VAULT_PASSWORD` so every protected route needs the bearer token. The [REST API](/docs/deployment/rest-api) page covers the token.

To serve the UI from another host, set `PIPEDASH_EMBEDDED_FRONTEND=false`. The server then answers `404` for everything outside `/api/v1`.

## Desktop app

The desktop app ignores `base_path`, together with `bind_addr` and `cors_allow_all`. It runs no web server.
