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
NetworkPolicyso 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:
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
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: