Visitar URL original
Configure external reverse proxy | Plane
Skip to content

Configure external reverse proxy Commercial Edition Community Edition ​

This page provides configuration for setting up an external reverse proxy with Plane. The setup below applies to Plane Commercial Edition; for Community Edition, see Community Edition.

Plane environment setup ​

Make sure to update the following environment variables in your plane.env file.

  1. Assign free ports for Plane to listen on. Update the following variables with two different unsused ports:

    bash
    LISTEN_HTTP_PORT=
    LISTEN_HTTPS_PORT=
  2. Update the SITE_ADDRESS variable to :80

    bash
      SITE_ADDRESS=:80

    This is required so that generated links and redirects work correctly behind the proxy:

  3. After editing plane.env, restart your instance so the changes take effect:

    bash
    sudo prime-cli restart

WARNING

Prime CLI is for Docker installations only. These commands only work on Plane instances originally installed using prime-cli.

Proxy setup ​

  1. Choose the appropriate configuration template for your reverse proxy.

  2. Replace the following placeholders:

    • <domain>
      Your Plane application's domain name.
    • <plane-host-ip>
      The IP address where Plane is hosted.
    • <plane-host-port>
      The port Plane listens on.
  3. For Traefik, also update your-email@example.com with your email.

Ensure that your reverse proxy setup follows the template provided, and that the forwarded headers and ports are correctly set to match the environment variable configuration.

Configuration templates ​

All configurations include:

  • Automatic HTTPS redirection
  • WebSocket support
  • Streaming responses for Plane AI and agent runs
  • Standard proxy headers
  • SSL/TLS certificate management
    • NGINX: Uses Certbot
    • Caddy: Handles certificates automatically
    • Traefik: Uses Let’s Encrypt
NGINX configuration
bash
server {
    server_name <domain>;

    location / {
        proxy_pass http://<plane-host-ip>:<plane-host-port>/;

        # Set headers for proxied request
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Forwarded-Host  $host;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Real-IP         $remote_addr;

        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $http_host;
        proxy_http_version 1.1;

        # Stream responses as they arrive (Plane AI, agent runs)
        proxy_buffering off;
        proxy_cache off;
        proxy_read_timeout 300s;
    }

    client_max_body_size 10M;

    listen 443 ssl; # managed by Certbot
    ssl_certificate /etc/letsencrypt/live/<domain>/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/<domain>/privkey.pem;
    include /etc/letsencrypt/options-ssl-nginx.conf;
    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
}

server {
    if ($host = <domain>) {
        return 301 https://$host$request_uri;
    }

    listen 80;
    server_name <domain>;
    return 404;
}
Caddy configuration
bash
<domain> {
    tls {
        # Caddy will automatically handle certificates
    }

    redir / https://{host}{uri} permanent

    reverse_proxy <plane-host-ip>:<plane-host-port> {
        header_up X-Forwarded-Proto {scheme}
        header_up X-Forwarded-Host {host}
        header_up X-Real-IP {remote_host}
        header_up X-Forwarded-For {remote_host}
        header_up Host {http.request.host}

        header_up Upgrade {http.request.header.Upgrade}
        header_up Connection {http.request.header.Connection}

        transport http {
            tls_insecure_skip_verify
            read_buffer 4096
            write_buffer 4096
        }
    }

    request_body {
        max_size 10MB
    }
}
Traefik configuration
bash
entryPoints:
  web:
    address: ":80"
    http:
      redirections:
        entryPoint:
          to: websecure
          scheme: https
          permanent: true

  websecure:
    address: ":443"

certificatesResolvers:
  letsencrypt:
    acme:
      email: your-email@example.com  # Replace with your email
      storage: acme.json
      httpChallenge:
        entryPoint: web

providers:
  http:
    routers:
      plane-router:
        rule: "Host(`<domain>`)"
        service: plane-service
        entryPoints:
          - websecure
        tls:
          certResolver: letsencrypt

    services:
      plane-service:
        loadBalancer:
          servers:
            - url: "http://<plane-host-ip>:<plane-host-port>"
          passHostHeader: true
          responseForwarding:
            flushInterval: "100ms"
          serversTransport:
            maxIdleConnsPerHost: 100
            forwardingTimeouts:
              dialTimeout: 30s
              responseHeaderTimeout: 30s
              idleConnTimeout: 90s

    middlewares:
      headers:
        headers:
          customRequestHeaders:
            X-Forwarded-Proto: "https"
            X-Real-IP: "{{ .RemoteAddr }}"

Streaming responses ​

Some Plane features stream their response to the browser as it is generated, over server-sent events (SSE), instead of returning it in one piece:

PathFeature
/pi/Plane AI chat and page summaries
/api/workspaces/<workspace>/runs/<run>/activities/stream/Agent run activity
/mcp/ (or your MCP_PATH_PREFIX)MCP server, when enabled

For these to work, your reverse proxy must:

  • Pass the response through as it arrives. A proxy that buffers responses holds each one until it is complete.
  • Keep long requests open. Allow at least 300 seconds before closing an idle upstream connection.

If something between the browser and Plane buffers, Plane AI answers appear all at once after a pause instead of word by word, agent-run activity only updates when the run ends, and MCP clients using the SSE transport never finish connecting.

NGINX ​

NGINX buffers upstream responses by default. The NGINX template above turns this off with:

nginx
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 300s;

Set these on the proxy even though some Plane services send an X-Accel-Buffering: no header: not every streaming endpoint does. To keep buffering for the rest of the site, put the three directives in a dedicated location /pi/ block, plus one for each other path above that you use.

Caddy ​

Caddy flushes text/event-stream responses immediately, so the Caddy template streams with no extra configuration.

Traefik ​

Traefik streams by default, so the Traefik template works as is. Do not attach a buffering middleware, often used to cap upload size with maxRequestBodyBytes, to the paths above: it holds the entire response until the upstream finishes, not just the request body.

Kubernetes ingress ​

The Plane Helm chart's ingress already handles streaming:

  • Traefik: the routes for the paths above have no buffering middleware. The other routes, file uploads included, keep the ingress.traefik.maxRequestBodyBytes limit.

  • ingress-nginx: response buffering is off by default (proxy-buffering: "off"). If your controller's ConfigMap turns it on, turn it back off for Plane through ingress.ingress_annotations:

    yaml
    ingress:
      ingress_annotations:
        nginx.ingress.kubernetes.io/proxy-buffering: "off"
        nginx.ingress.kubernetes.io/proxy-read-timeout: "300"
  • OpenShift: the chart sets a 300-second router timeout on its Routes (ingress.openshift.timeout).

If you add your own ingress or middleware in front of the chart's, apply the same rules to it.

Check that streaming works ​

  1. Open Plane in your browser and open the developer tools' Network tab.
  2. Ask Plane AI a question.
  3. Select the streaming request (its type is eventsource, or its response type is text/event-stream). Its EventStream or Response view should fill in gradually while the answer is being written.

If the request shows nothing until the answer is complete, a proxy in the path is buffering it.

Community Edition ​

Plane Community Edition ships with a bundled Caddy reverse proxy. To put your own reverse proxy in front of it, disable the bundled proxy and route traffic to the individual containers yourself.

1. Disable the bundled proxy ​

To use your own reverse proxy, you must first disable the built-in proxy service. Open your docker-compose.yml file and comment out or remove the proxy service at the end of the file.

2. Connect to the Plane Docker network ​

Your custom reverse proxy needs to resolve the internal container hostnames (like web, api, admin). The easiest way to achieve this is to run your reverse proxy container on the same Docker network as Plane (usually plane-app_default), or map all necessary ports from the individual containers to your host machine.

3. Route mapping ​

Plane Community Edition relies on path-based routing to direct traffic to different services. You must map the following paths to their respective containers and internal ports:

Route pathDestination containerInternal port
/* (catch-all)web3000
/spaces/*space3000
/god-mode/*admin3000
/live/*live3000
/api/*, /auth/*, /static/*api8000
/<bucket-name>/* (default: /uploads)plane-minio9000

4. Configuration templates ​

Below are example configurations for NGINX, Caddy, and Traefik, assuming your proxy runs on the same Docker network as the Plane containers.

NGINX configuration
bash
server {
    listen 80;
    server_name <domain>;

    # File uploads (MinIO)
    # Replace 'uploads' with your configured AWS_S3_BUCKET_NAME if changed
    location ~ ^/uploads(/.*)?$ {
        proxy_pass http://plane-minio:9000;
        proxy_set_header Host $http_host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    # API, Auth, and Static
    location ~ ^/(api|auth|static)(/.*)?$ {
        proxy_pass http://api:8000;
        proxy_set_header Host $http_host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    # Live (WebSockets)
    location /live/ {
        proxy_pass http://live:3000;
        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-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    # God mode (Admin)
    location /god-mode/ {
        proxy_pass http://admin:3000;
        proxy_set_header Host $http_host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    # Spaces
    location /spaces/ {
        proxy_pass http://space:3000;
        proxy_set_header Host $http_host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    # Frontend (Catch-all)
    location / {
        proxy_pass http://web:3000;
        proxy_set_header Host $http_host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    client_max_body_size 50M;
}
Caddy configuration
bash
<domain> {
    request_body {
        max_size 50MB
    }

    redir /spaces /spaces/ permanent
    reverse_proxy /spaces/* space:3000

    redir /god-mode /god-mode/ permanent
    reverse_proxy /god-mode/* admin:3000

    reverse_proxy /live/* live:3000

    reverse_proxy /api/* api:8000
    reverse_proxy /auth/* api:8000
    reverse_proxy /static/* api:8000

    # Replace 'uploads' with your actual bucket name if changed
    reverse_proxy /uploads/* plane-minio:9000
    reverse_proxy /uploads plane-minio:9000

    reverse_proxy /* web:3000
}
Traefik configuration

With Traefik, you can apply labels directly in your docker-compose.yml to the respective containers instead of a static configuration file. Assuming Traefik is set up to read Docker labels:

yaml
services:
  web:
    # ...
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.plane-web.rule=Host(`<domain>`)"
      - "traefik.http.services.plane-web.loadbalancer.server.port=3000"

  api:
    # ...
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.plane-api.rule=Host(`<domain>`) && (Path(`/api`) || PathPrefix(`/api/`) || Path(`/auth`) || PathPrefix(`/auth/`) || Path(`/static`) || PathPrefix(`/static/`))"
      - "traefik.http.services.plane-api.loadbalancer.server.port=8000"

  admin:
    # ...
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.plane-admin.rule=Host(`<domain>`) && (Path(`/god-mode`) || PathPrefix(`/god-mode/`))"
      - "traefik.http.services.plane-admin.loadbalancer.server.port=3000"

  space:
    # ...
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.plane-space.rule=Host(`<domain>`) && (Path(`/spaces`) || PathPrefix(`/spaces/`))"
      - "traefik.http.services.plane-space.loadbalancer.server.port=3000"

  live:
    # ...
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.plane-live.rule=Host(`<domain>`) && (Path(`/live`) || PathPrefix(`/live/`))"
      - "traefik.http.services.plane-live.loadbalancer.server.port=3000"

  plane-minio:
    # ...
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.plane-minio.rule=Host(`<domain>`) && (Path(`/uploads`) || PathPrefix(`/uploads/`))"
      - "traefik.http.services.plane-minio.loadbalancer.server.port=9000"