Deploy the MPC committee to Render
This page runs the SODA v0.5 MPC stack on Render. You create three web
services: mpc-node-p1, mpc-node-p2 and mpc-coordinator. The free plan
is enough for a demo.
Use Deploy MPC to AWS instead if you need cross-region nodes or instances that never sleep.
On Render’s free plan the two nodes get public URLs. Render’s free web services cannot receive private-network traffic, so the coordinator must call the nodes over the public internet. A bearer token is therefore mandatory, not optional. Without it, anyone who finds a node URL can run the protocol and get a valid committee signature on any payload.
How Render differs from AWS
| Concern | AWS | Render free plan |
|---|---|---|
| Node isolation | Security groups allow only the coordinator’s private IP | Public URL plus MPC_AUTH_TOKEN |
| Share storage | scp to /data/share-pN.json | Secret File at /etc/secrets/share-pN.json |
| Availability | Always on | Sleeps after 15 idle minutes, wakes in about 1 minute |
| Warm sign time | About 400 ms | A few seconds — a free instance gets 0.1 CPU |
| Cost | Three t3.small | $0 |
Private services (type: pserv) are never free on Render. If you want the
AWS isolation model, put the two nodes on a paid plan as private services
and keep only the coordinator public.
Architecture
subscriber / web / demo
│ POST /sign (Authorization: Bearer <coordinator token>)
▼
┌──────────────────────┐
│ soda-mpc-coordinator │ public web service
│ holds no share │
└───────┬──────────────┘
│ HTTPS + Bearer <node token>
┌─────┴──────┐
▼ ▼
┌──────────┐ ┌──────────┐
│ node p1 │ │ node p2 │ public web services
│ share P1 │ │ share P2 │ share mounted at /etc/secrets
└──────────┘ └──────────┘The coordinator never holds a share. It only forwards opaque protocol bytes. Compromise of the coordinator gives denial of service, not key theft.
Before you start
-
Generate the shares on your laptop:
pnpm install pnpm mpc:dkgThis writes
apps/mpc-node/shares/share-p1.jsonandshare-p2.json. It also prints the jointgroup_pk. Keep that output. -
Generate two random tokens:
node -e "const c=require('node:crypto'); console.log('node token:', c.randomBytes(32).toString('base64url')); console.log('coord token:', c.randomBytes(32).toString('base64url'))"
Create soda-mpc-node-p1
In the Render dashboard select New → Web Service, then pick your repo.
| Field | Value |
|---|---|
| Name | soda-mpc-node-p1 |
| Language | Docker |
| Root Directory | (leave empty) |
| Dockerfile Path | apps/mpc-node/Dockerfile |
| Instance Type | Free |
| Health Check Path | /health |
Add these environment variables:
MPC_ROLE=p1
PORT=8001
MPC_SHARE_PATH=/etc/secrets/share-p1.json
MPC_AUTH_TOKEN=<node token>Add a Secret File named share-p1.json. Paste the whole contents of
apps/mpc-node/shares/share-p1.json. Render mounts it at
/etc/secrets/share-p1.json.
Create soda-mpc-node-p2
Repeat the previous step with these differences:
| Field | Value |
|---|---|
| Name | soda-mpc-node-p2 |
| Dockerfile Path | apps/mpc-node/Dockerfile |
MPC_ROLE=p2
PORT=8002
MPC_SHARE_PATH=/etc/secrets/share-p2.json
MPC_AUTH_TOKEN=<the same node token>Secret File share-p2.json holds apps/mpc-node/shares/share-p2.json.
Give p1 the p1 share and p2 the p2 share. The node compares MPC_ROLE
against the role field in the share file and refuses to start on a
mismatch.
Create soda-mpc-coordinator
| Field | Value |
|---|---|
| Name | soda-mpc-coordinator |
| Language | Docker |
| Dockerfile Path | apps/mpc-coordinator/Dockerfile |
| Instance Type | Free |
| Health Check Path | /health |
PORT=8000
MPC_NODE_P1_URL=https://soda-mpc-node-p1.onrender.com
MPC_NODE_P2_URL=https://soda-mpc-node-p2.onrender.com
MPC_NODE_AUTH_TOKEN=<node token>
MPC_AUTH_TOKEN=<coordinator token>Use the real URLs Render shows on each node service.
Check the committee
curl https://soda-mpc-coordinator.onrender.com/healthBoth peers must report the same groupPkXY:
{"ok":true,"peers":{
"p1":{"ok":true,"role":"p1","groupPkXY":{"x":"9e4c1ac3…","y":"4794e02a…"}},
"p2":{"ok":true,"role":"p2","groupPkXY":{"x":"9e4c1ac3…","y":"4794e02a…"}}}}Confirm the nodes reject strangers:
curl -o /dev/null -w '%{http_code}\n' -X POST \
https://soda-mpc-node-p1.onrender.com/sign/init \
-H 'content-type: application/json' -d '{}'
# 401Then take a real signature:
curl -X POST https://soda-mpc-coordinator.onrender.com/sign \
-H 'content-type: application/json' \
-H "authorization: Bearer $MPC_COORDINATOR_TOKEN" \
-d '{"payloadHex":"0000000000000000000000000000000000000000000000000000000000000001"}'
# {"r":"…","s":"…","v":0}Point the on-chain committee at the new key
The DKG produced a new joint key, so the Committee PDA still holds the old
one. Update it:
cd contracts && anchor build && cd ..
ANCHOR_WALLET=~/.config/solana/id.json \
SOLANA_DEVNET_RPC_URL=https://api.devnet.solana.com \
pnpm mpc:update-committeeThe wallet must be the Committee.authority that called init_committee.
Wire the callers
Every caller of the coordinator now needs the token.
MPC_COORDINATOR_URL=https://soda-mpc-coordinator.onrender.com
MPC_COORDINATOR_TOKEN=<coordinator token>Set both in the repo .env for pnpm mpc:subscribe and ./demo.sh, and in
the Vercel project environment for apps/web.
Delete the local shares
Once the nodes work, remove the copies on your laptop:
rm -P apps/mpc-node/shares/share-p1.json apps/mpc-node/shares/share-p2.jsonAfter this, no single host holds both shares. That is the property MPC is supposed to give you.
Before a demo, warm the committee
A free instance sleeps after 15 idle minutes. The first sign after a sleep can take two minutes, which looks broken on stage.
MPC_COORDINATOR_URL=https://soda-mpc-coordinator.onrender.com \
MPC_COORDINATOR_TOKEN=<coordinator token> \
bash scripts/warm-mpc-render.shGET /health fans out to both nodes in parallel, so one call wakes all
three services. The coordinator also pre-warms both peers in parallel at the
start of every /sign. Set MPC_PREWARM_PEERS=0 to turn that off.
Single-service mode
Dockerfile.mpc-aio runs p1, p2 and the coordinator in one container, under
the supervisor at scripts/mpc-all-in-one.mjs. You then create one Render
web service instead of three.
This puts both shares on one filesystem. Anyone who reaches inside the container can rebuild the joint secret, so the committee becomes a single-key signer running a protocol. Use it for a demo, and do not claim the 2-of-2 property while you do. The three-service setup above is what makes that claim true.
Only the coordinator listens on the platform’s $PORT. Both nodes bind
loopback ports that Render does not route, and the supervisor mints a random
per-boot token that the nodes require, so you never configure one.
| Field | Value |
|---|---|
| Name | soda-mpc |
| Language | Docker |
| Dockerfile Path | Dockerfile.mpc-aio |
| Instance Type | Free |
| Health Check Path | /health |
One environment variable:
MPC_AUTH_TOKEN=<coordinator token>Two Secret Files, share-p1.json and share-p2.json.
The supervisor exits if any of the three processes dies, so Render restarts the whole container rather than leaving a stack that answers the health check but cannot sign.
To go back to three services later, nothing in apps/mpc-node or
apps/mpc-coordinator has to change. The supervisor only sets environment
variables that those services already read.
If you have Render credit
Render prorates compute by the second. Use credit on the demo window, not on one service all month.
Do not upgrade only one of the three services. Cold start hits every service that sleeps, so two sleeping nodes still make the first signature slow.
| Setup | Cost | First sign after idle |
|---|---|---|
| 3 × Free | $0 | 1 to 2 minutes |
| 3 × Starter | $21/month, or $0.69/day | Never sleeps, about 400 ms |
$7 of credit runs all three on Starter for about 10 days. Starter also gives 0.5 CPU against Free’s 0.1, and paid instances do not consume the 750 free instance-hours.
- Build and test on Free. This costs nothing.
- Switch all three to Starter one day before the demo.
- Switch them back to Free afterwards.
Change the plan under Settings → Instance Type on each service. The URL does not change, and a web service redeploys with no downtime.
Keep a payment method on file. When the credit runs out, Render suspends paid services.
Watch the free-tier quota
Render gives each workspace 750 free instance hours per calendar month. Three always-awake services use about 2160 hours. The sleep behaviour is what keeps you inside the quota, so do not add an uptime pinger to all three services. If you exceed the quota, Render suspends every free web service until the next month.
Known limit: tweakHex does not work
The coordinator accepts a tweakHex argument, but the committee ignores it.
A tweaked request returns a signature that recovers to plain group_pk, not
to group_pk + tweak·G.
The cause is in the Lindell ‘17 protocol, not in the deployment. P2 holds
cypher_x1, a Paillier encryption of P1’s share made during the DKG. P1
computes the final s from that ciphertext, so editing P1’s local x1
before signing changes nothing.
Two consequences:
apps/webalready works around this. It sends no tweak and storesforeign_pk_xy = group_pk_xy, so one Solana address maps to one Ethereum address.apps/mpc-subscriberdoes send a real tweak. The signature it gets back fails the on-chainforeign_pk_xycomparison infinalize_signature.
Per-PDA foreign addresses under MPC need a protocol that supports key tweaking, such as GG20 or CGG21. That is v1 work.
Trust caveats
These are the same as the AWS deployment, plus one:
- 2-of-2 has zero fault tolerance. Both nodes must be up to sign.
- Both nodes run under one Render account. Real decentralization needs different operators.
- Shares sit in Render Secret Files as plaintext. Production wants a Nitro Enclave or a KMS wrap.
- New on Render: the nodes are reachable from the public internet. A
leaked
MPC_AUTH_TOKENis equivalent to a leaked signing key. Rotate it by editing the variable on all three services.