Skip to content

Behind a reverse proxy

Configure BlackBull's trusted proxies and use the examples below to connect nginx, HAProxy, or Envoy to its HTTP/1.1 or HTTP/2 listener.

Common setup — the BlackBull side

Start BlackBull without TLS. The proxy handles certificates:

python app.py --port 8000   # plain HTTP; no --cert / --key

Trusted-proxy headers

By default conn.client is the raw TCP peer — the proxy's address — and conn.scheme is 'http' even when the client connected over HTTPS. Enable TrustedProxy to recover both:

# Shortcut on BlackBull — registers TrustedProxy automatically
app = BlackBull(trusted_proxies=['127.0.0.1', '::1'])

# Or register explicitly for more control (e.g. a private subnet):
from blackbull import TrustedProxy
app.use(TrustedProxy(['127.0.0.1', '::1', '10.0.0.0/8']))

Set trusted_proxies to the proxies' actual source addresses; other peers' forwarding headers are ignored. Each trusted proxy must append its observed peer or overwrite the address chain. conn.client identifies the nearest untrusted hop, which may itself be a proxy.

BlackBull prefers Forwarded over X-Forwarded-For and X-Forwarded-Proto. The XFF-based examples below therefore remove incoming Forwarded and overwrite X-Forwarded-Proto. They also remove X-Forwarded-Prefix; if mounting under /api, overwrite it with /api instead. Proto and prefix must each be a single value, not a list accumulated across proxies.

nginx

Terminates TLS and HTTP/2 toward clients, proxies to BlackBull over HTTP/1.1. Regular requests, WebSocket upgrades, and Server-Sent Events all work.

upstream blackbull {
    server 127.0.0.1:8000;
    keepalive 64;
}

server {
    listen 443 ssl;
    http2 on;
    server_name example.com;

    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

    # ── Regular HTTP requests ─────────────────────────────────────────
    location / {
        proxy_pass         http://blackbull;
        proxy_http_version 1.1;
        proxy_set_header   Host              $host;
        proxy_set_header   X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header   X-Forwarded-Proto $scheme;
        proxy_set_header   Forwarded "";
        proxy_set_header   X-Forwarded-Prefix "";
        proxy_set_header   Connection        "";   # enable keep-alive upstream
    }

    # ── WebSocket ─────────────────────────────────────────────────────
    # WebSocket requires HTTP/1.1 Upgrade; match paths that need it explicitly.
    location /ws {
        proxy_pass         http://blackbull;
        proxy_http_version 1.1;
        proxy_set_header   Host       $host;
        proxy_set_header   X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header   X-Forwarded-Proto $scheme;
        proxy_set_header   Forwarded "";
        proxy_set_header   X-Forwarded-Prefix "";
        proxy_set_header   Upgrade    $http_upgrade;
        proxy_set_header   Connection "upgrade";
        proxy_read_timeout 3600s;     # keep WS connection open
    }

    # ── Server-Sent Events ────────────────────────────────────────────
    location /sse {
        proxy_pass                http://blackbull;
        proxy_http_version        1.1;
        proxy_set_header          Host $host;
        proxy_set_header          X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header          X-Forwarded-Proto $scheme;
        proxy_set_header          Forwarded "";
        proxy_set_header          X-Forwarded-Prefix "";
        proxy_set_header          Connection "";
        proxy_buffering           off;      # flush SSE events immediately
        proxy_cache               off;
        proxy_read_timeout        3600s;
        chunked_transfer_encoding on;
    }
}

# Redirect plain HTTP to HTTPS
server {
    listen 80;
    server_name example.com;
    return 301 https://$host$request_uri;
}

HAProxy

HTTP/1.1 backend

global
    log stdout format raw local0

defaults
    mode    http
    timeout connect 5s
    timeout client  60s
    timeout server  60s

frontend https_in
    bind *:443 ssl crt /etc/haproxy/certs/example.com.pem alpn h2,http/1.1
    bind *:80
    http-request redirect scheme https unless { ssl_fc }

    http-request del-header Forwarded
    http-request del-header X-Forwarded-Prefix
    # Standalone metadata must replace client-supplied values.
    http-request set-header X-Forwarded-Proto https if { ssl_fc }
    http-request set-header X-Forwarded-Proto http  unless { ssl_fc }
    option forwardfor                       # appends X-Forwarded-For

    default_backend blackbull

backend blackbull
    # WebSocket needs no special handling: HAProxy tunnels the Upgrade
    # automatically in HTTP mode.  Raise the server timeout for long-lived
    # connections (WebSocket, SSE).
    timeout tunnel 3600s
    server bb1 127.0.0.1:8000 check

HTTP/2 backend

Change one thing — the server line. proto h2 forces cleartext HTTP/2 (h2c) to the backend:

backend blackbull
    timeout tunnel 3600s
    server bb1 127.0.0.1:8000 proto h2 check

Over TLS to the backend, negotiate it with ALPN instead:

    server bb1 10.0.0.5:8443 ssl alpn h2 verify required \
        ca-file /etc/haproxy/certs/ca.pem check

WebSocket over an HTTP/2 backend

WebSocket over HTTP/2 uses Extended CONNECT (RFC 8441), which is a different mechanism from the HTTP/1.1 Upgrade handshake. BlackBull supports it, but if you hit trouble, route WebSocket paths to an HTTP/1.1 backend and keep HTTP/2 for the rest:

frontend https_in
    acl is_ws path_beg /ws
    use_backend blackbull_h1 if is_ws
    default_backend blackbull_h2

Envoy

HTTP/1.1 backend

static_resources:
  listeners:
  - name: https_listener
    address:
      socket_address: {address: 0.0.0.0, port_value: 443}
    filter_chains:
    - filters:
      - name: envoy.filters.network.http_connection_manager
        typed_config:
          "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
          stat_prefix: ingress_http
          use_remote_address: true      # appends the observed peer to XFF
          xff_num_trusted_hops: 0        # overwrites client-supplied proto
          upgrade_configs:
          - upgrade_type: websocket     # required for WebSocket
          route_config:
            request_headers_to_remove: [forwarded, x-forwarded-prefix]
            virtual_hosts:
            - name: backend
              domains: ["*"]
              routes:
              - match: {prefix: "/"}
                route:
                  cluster: blackbull
                  timeout: 0s           # no timeout: SSE / streaming
          http_filters:
          - name: envoy.filters.http.router
            typed_config:
              "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
      transport_socket:
        name: envoy.transport_sockets.tls
        typed_config:
          "@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.DownstreamTlsContext
          common_tls_context:
            alpn_protocols: ["h2", "http/1.1"]
            tls_certificates:
            - certificate_chain: {filename: "/etc/envoy/certs/fullchain.pem"}
              private_key:       {filename: "/etc/envoy/certs/privkey.pem"}

  clusters:
  - name: blackbull
    connect_timeout: 5s
    type: STATIC
    load_assignment:
      cluster_name: blackbull
      endpoints:
      - lb_endpoints:
        - endpoint:
            address:
              socket_address: {address: 127.0.0.1, port_value: 8000}

HTTP/2 backend

Add typed_extension_protocol_options to the cluster. Everything else stays as above:

  clusters:
  - name: blackbull
    connect_timeout: 5s
    type: STATIC
    typed_extension_protocol_options:
      envoy.extensions.upstreams.http.v3.HttpProtocolOptions:
        "@type": type.googleapis.com/envoy.extensions.upstreams.http.v3.HttpProtocolOptions
        explicit_http_config:
          http2_protocol_options: {}      # h2c to the backend
    load_assignment:
      cluster_name: blackbull
      endpoints:
      - lb_endpoints:
        - endpoint:
            address:
              socket_address: {address: 127.0.0.1, port_value: 8000}

For gRPC, this is the configuration you want — Envoy will multiplex all RPCs onto one upstream connection. See gRPC.

Docker

In Compose, the proxy reaches BlackBull by service name, so the trusted set must cover the Docker network rather than loopback:

app = BlackBull(trusted_proxies=['172.16.0.0/12'])

Going one step further — AF_UNIX

When the proxy and BlackBull share a host, an AF_UNIX socket between them removes TCP overhead and avoids exposing a port on 0.0.0.0. See Unix and fd inheritance.

# nginx
upstream blackbull { server unix:/run/blackbull.sock; }
# HAProxy
backend blackbull
    server bb1 /run/blackbull.sock
# Envoy — in the cluster's endpoint address
address:
  pipe: {path: /run/blackbull.sock}

With a Unix socket there is no meaningful IP to trust, so TrustedProxy needs the peer that the socket presents rather than a network range — verify what conn.client reports before assuming a value.

Choosing the upstream protocol

A proxy can terminate TLS and HTTP/2 and use HTTP/1.1 upstream. Choose an upstream protocol that preserves the application's required features: gRPC and multiplexed streams require HTTP/2 support along their path.

Next

  • Workers — multi-worker is a natural fit behind a proxy; each worker can saturate one core while the proxy load-balances.
  • Unix and fd inheritance — the AF_UNIX bind pattern.
  • TLS — if you decide to terminate TLS in BlackBull instead.
  • HTTP/2 — what the native HTTP/2 support gives you.