Skip to content

Several backends behind one port

One Doppel process, one listener, several upstreams. A request picks its proxy by carrying a header that names it; anything that does not is handled by the default.

This is what lets a test suite point at a single address and still reach a staging backend, a production-shaped one and a fully mocked one, without restarting anything or juggling ports.

A worked example

server:
  host: "127.0.0.1"
  port: 8080

admin:
  host: "127.0.0.1"
  port: 8081
  tokens: []
  access: {}
  upload:
    limit: 1Mi

proxies:
  # Everything that does not ask for something else.
  - name: production
    type: http
    url: "https://api.example.com/v1/"
    resolve:
      type: default

  # Reached by `X-Backend: staging`.
  - name: staging
    type: http
    url: "https://staging.example.com/v1/"
    resolve:
      type: header
      header: X-Backend
    latency:
      percentage: 1.0
      min: 0.3
      max: 0.3

  # Reached by `X-Backend: offline`. No upstream is ever contacted for the
  # paths its mocks cover; everything else still goes to the url below.
  - name: offline
    type: http
    url: "https://api.example.com/v1/"
    resolve:
      type: header
      header: X-Backend
    mocks:
      - name: catalogue
        request:
          method: GET
          url: /catalogue/
        response:
          status: 200
          json: '{"items": []}'
curl -s localhost:8080/catalogue/                         # production
curl -s -H 'X-Backend: staging' localhost:8080/catalogue/ # staging, +300ms
curl -s -H 'X-Backend: offline' localhost:8080/catalogue/ # {"items": []}

The rules resolution follows

One default at most. Two proxies declaring type: default is refused at load. Zero is legal: a request matching no header then gets 404 with PROXY_NOT_RESOLVED, which is a reasonable configuration for a process that should only ever serve traffic that asks for something specific.

Headers are tried in configuration order. A request carrying two resolution headers resolves to whichever proxy is written first, on every process and every run. That is deterministic rather than arbitrary, which matters when the same configuration runs in three places.

A name only works through its own header. With staging resolving on X-Backend, a request carrying X-Proxy-Name: staging does not reach it. Otherwise every proxy would be reachable through every resolution header and the per-proxy setting would be decorative.

Resolution headers are stripped before forwarding. The upstream does not learn Doppel's routing vocabulary.

Different headers for different groups

Nothing requires one shared header name:

  - name: eu
    resolve:
      type: header
      header: X-Region
  - name: us
    resolve:
      type: header
      header: X-Region
  - name: canary
    resolve:
      type: header
      header: X-Canary

X-Region: eu reaches the first, X-Canary: canary the third. A request carrying both X-Region: us and X-Canary: canary reaches us, because it is written first.

Per-proxy access control

Each proxy can narrow who may read or change it through the admin API, for the four actions a proxy may override:

  - name: production
    access:
      read: ["sre"]
      update: ["sre"]
      delete: ["sre"]
      upload: ["sre"]

list and create cannot be overridden per proxy -- they are not about one proxy -- and a configuration trying to is refused at parse time.

See The admin API.

Watching which proxy served what

The proxy label is on every request metric:

doppel_requests_total{proxy="staging",method="GET",status="200"} 41
doppel_requests_total{proxy="production",method="GET",status="200"} 900

There is deliberately no path label. See Observability.