Skip to content

Deployment

RPC Plane is a single binary (and a single ~20 MB container image) with no database and no external dependencies. That makes it easy to run almost anywhere — as a standalone service, or as a sidecar next to your app.

It is an internal component: your app talks to it, and it talks to your RPC providers. It should never be exposed to the public internet. Keep its ports on loopback, a private network, or a ClusterIP.

Every manifest below references provider keys as ${HELIUS_API_KEY}-style variables. Supply them as environment variables and RPC Plane expands them when it loads the config. Run rpc-plane check afterwards to confirm none expanded to an empty string.

Copy-paste-ready versions of everything here live in examples/deploy/.

Network exposure & authentication

RPC Plane has no authentication on its listener, by design. Like a database connection pooler or a local cache, it is meant to sit inside your trust boundary: anything that can reach its port can send requests through it — which means using the provider keys in your config and spending their quota. There is nothing to log in to and no token to present, so the security boundary is the network, not the proxy.

Keep it off the public internet:

  • Bind loopback or a private interface. Use 127.0.0.1:9400 — or a Unix socket, which exposes no port at all — for co-located clients. Bind a routable address only when clients run on other hosts, and only on a private network.
  • Never front it with a public LoadBalancer or Ingress. In Kubernetes keep the Service ClusterIP.
  • Restrict who can reach the port. Firewall it to your app hosts, or in Kubernetes attach a NetworkPolicy so only your application pods can open it.

The metrics endpoint (metrics_listen) is unauthenticated too — scrape it from your monitoring network, not the internet.

Port or socket?

The one decision that shapes every deployment is how RPC Plane listens:

TCP port (0.0.0.0:9400) Unix socket (/run/rpc-plane/proxy.sock)
Reachable from any host/pod over the network same host or pod only
Overhead loopback TCP stack none — kernel socket path
Network exposure a real port (firewall it) no port at all
Use when proxy and clients are on different hosts proxy and client are co-located
Examples here Compose, K8s Deployment, Nomad K8s sidecar, systemd on the same box

Co-located deployments (a sidecar, or the proxy and app on one VM) should prefer a Unix socket — it removes the loopback hop entirely and exposes no port. The trade-off: some Solana client libraries only dial TCP URLs. The Unix socket guide covers the connection details, the permission model, and the measured latency win.

The metrics endpoint (metrics_listen) always binds to TCP, regardless of how the proxy listens — so Prometheus can scrape it either way.

Docker

docker run -d --name rpc-plane \
  -v $(pwd)/rpc-plane.toml:/etc/rpc-plane.toml:ro \
  -e HELIUS_API_KEY=... \
  -e QUICKNODE_API_KEY=... \
  -p 9400:9400 -p 9401:9401 \
  ghcr.io/rpcplane/rpc-plane:latest

The image expects the config at /etc/rpc-plane.toml and runs rpc-plane -c /etc/rpc-plane.toml run by default. Inside a container the config must bind 0.0.0.0, not 127.0.0.1, for the published port to work:

[server]
listen         = "0.0.0.0:9400"
metrics_listen = "0.0.0.0:9401"

Docker Compose

A minimal stack — one service, keys from a .env file:

services:
  rpc-plane:
    image: ghcr.io/rpcplane/rpc-plane:latest
    restart: unless-stopped
    env_file: .env
    volumes:
      - ./rpc-plane.toml:/etc/rpc-plane.toml:ro
    ports:
      - "9400:9400"   # proxy
      - "9401:9401"   # metrics
    healthcheck:
      # The runtime image has no curl; use the built-in status command.
      test: ["CMD", "rpc-plane", "-c", "/etc/rpc-plane.toml", "status"]
      interval: 15s
      timeout: 5s
      retries: 3
cp .env.example .env   # add your keys
docker compose up -d
curl http://localhost:9400/health | jq

The repo ships an observability overlay that adds Prometheus and Grafana with the RPC Plane dashboard auto-provisioned:

docker compose -f docker-compose.yml -f docker-compose.observability.yml up -d
# Grafana → http://localhost:3000   Prometheus → http://localhost:9090

Full files: examples/deploy/docker-compose/.

Kubernetes

Shared Deployment (TCP)

Run the proxy as its own Deployment + ClusterIP Service when several apps share one proxy. They reach it at http://rpc-plane:9400. Keys go in a Secret, config in a ConfigMap; both probes hit /health:

        - name: rpc-plane
          image: ghcr.io/rpcplane/rpc-plane:latest
          envFrom:
            - secretRef: { name: rpc-plane-keys }
          ports:
            - { name: rpc, containerPort: 9400 }
            - { name: metrics, containerPort: 9401 }
          readinessProbe:
            httpGet: { path: /health, port: rpc }
          livenessProbe:
            httpGet: { path: /health, port: rpc }
          volumeMounts:
            - name: config
              mountPath: /etc/rpc-plane.toml
              subPath: rpc-plane.toml

Full manifest (Secret + ConfigMap + Deployment + Service, with a hardened securityContext): kubernetes/deployment.yaml.

Sidecar (Unix socket)

Co-locate the proxy in your app's pod and share a Unix socket through an emptyDir volume — no proxy port, no loopback overhead:

      securityContext:
        runAsUser: 1000      # same UID in both containers so the app
        runAsGroup: 1000     # can connect to the socket the proxy creates
        fsGroup: 1000
      containers:
        - name: app
          image: your-app:latest
          volumeMounts:
            - { name: rpc-plane-socket, mountPath: /run/rpc-plane }
        - name: rpc-plane
          image: ghcr.io/rpcplane/rpc-plane:latest
          # listen = "/run/rpc-plane/proxy.sock" in the mounted config
          ports:
            - { name: metrics, containerPort: 9401 }
          # httpGet can't probe a socket — probe the always-TCP metrics port.
          livenessProbe:
            httpGet: { path: /metrics, port: metrics }
          volumeMounts:
            - { name: config, mountPath: /etc/rpc-plane.toml, subPath: rpc-plane.toml }
            - { name: rpc-plane-socket, mountPath: /run/rpc-plane }
      volumes:
        - { name: rpc-plane-socket, emptyDir: {} }

If your Solana client can't speak HTTP over a Unix socket, set listen = "127.0.0.1:9400" instead and point the app at http://localhost:9400 — containers in a pod share localhost. Full manifest (with that variant in the comments): kubernetes/sidecar.yaml.

Restrict access with a NetworkPolicy

Because the proxy port is unauthenticated, gate it at the network layer so only your app pods can reach it. The Deployment manifest bundles a NetworkPolicy that allows ingress to 9400 only from pods you label rpc-plane-client: "true", and ingress to the metrics port 9401 only from your monitoring namespace:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: rpc-plane
spec:
  podSelector:
    matchLabels:
      app: rpc-plane
  policyTypes: [Ingress]
  ingress:
    - from:
        - podSelector:
            matchLabels:
              rpc-plane-client: "true"   # label your app pods with this
      ports:
        - { protocol: TCP, port: 9400 }
    - from:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: monitoring   # where Prometheus runs
      ports:
        - { protocol: TCP, port: 9401 }

Enforcement needs a CNI that honours NetworkPolicy (Calico, Cilium, and most managed clusters). It ships in kubernetes/deployment.yaml — remove the document if your cluster gates traffic another way.

Metrics

If you run the Prometheus Operator, scrape the metrics port with a ServiceMonitor.

Nomad

The docker driver with a template stanza for the config and a Consul service with an HTTP health check:

    task "rpc-plane" {
      driver = "docker"
      config {
        image = "ghcr.io/rpcplane/rpc-plane:latest"
        ports = ["proxy", "metrics"]
        args  = ["-c", "/local/rpc-plane.toml", "run"]
      }
      env { HELIUS_API_KEY = "..." }   # or pull from Vault
      template {
        destination = "local/rpc-plane.toml"
        # In an HCL heredoc, escape ${ as $$ so Nomad leaves the env ref for
        # RPC Plane to expand: url = "...?api-key=$${HELIUS_API_KEY}"
        data = <<-EOF
          [server]
          listen = "0.0.0.0:9400"
          ...
        EOF
      }
    }

Full job (network ports, Consul service + health check, Vault example): nomad/rpc-plane.nomad.hcl.

systemd (bare metal / VM)

Run the binary directly under a hardened unit:

[Service]
Type=simple
User=rpc-plane
ExecStart=/usr/local/bin/rpc-plane -c /etc/rpc-plane/rpc-plane.toml run
EnvironmentFile=/etc/rpc-plane/rpc-plane.env
Restart=on-failure

# Unix socket mode: set listen = "/run/rpc-plane/proxy.sock" and uncomment —
# systemd creates /run/rpc-plane owned by the service user.
# RuntimeDirectory=rpc-plane

NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true

Full unit (complete hardening set) and EnvironmentFile template: systemd/.

Health checks

Endpoint Port Always TCP? Use for
/health proxy (9400 or socket) no — follows listen readiness/liveness on TCP deployments; rich JSON of per-provider health
/metrics metrics_listen (9401) yes liveness when the proxy is on a Unix socket; Prometheus scraping

On a Unix socket, httpGet probes can't reach /health, so probe /metrics (always TCP) for liveness. The full health JSON is still served on the socket and readable from a co-located container:

curl --unix-socket /run/rpc-plane/proxy.sock http://localhost/health | jq