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/.
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.
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: