Production deployment
Everything so far ran under the orchestrator — a development tool. On a real Linux server you run the pieces yourself. It's less magic than it sounds: two processes, a database, and a web server.
- No orchestrator. It exists to boot local chains; in production the chains are real networks you connect to.
- The node is a process —
bun packages/node/main.mainnet.ts, kept alive by systemd. - The batcher is a process —
bun packages/batcher/batcher.mainnet.ts, its own systemd unit. - The database must already be running — real PostgreSQL replaces dev's in-memory PGLite.
- The frontend is static files — any web server or CDN.
- A reverse proxy (nginx) puts the node API and batcher behind one HTTPS domain.
- Secrets live in an .env file — batcher key, DB password. Never in git.
- Config changes: real RPC URLs, deployed contract addresses, and start blocks.
The target picture
Dev vs production, piece by piece
| In dev (orchestrator) | In production (you) |
|---|---|
| local Hardhat chains | real networks via an RPC provider |
| local Midnight node/indexer/proof server | Midnight network endpoints |
| PGLite, in-memory, wiped each run | PostgreSQL, persistent, backed up |
| contracts compiled + deployed each boot | deployed once; addresses go in config |
| processes supervised by the orchestrator | two systemd units |
main.dev.ts / config.dev.ts | main.mainnet.ts / config.mainnet.ts |
Notice what's not in the table: your game code. grammar.ts,
state-machine.ts, game.ts, and api.ts run unchanged —
main.mainnet.ts imports the very same files.
1Database first
Dev used PGLite (PGLITE=true). Production points the same code at a real Postgres
through env vars read by @effectstream/db:
sudo apt install postgresql
sudo -u postgres createuser gridbeasts -P
sudo -u postgres createdb -O gridbeasts gridbeasts
Migrations still apply automatically at node startup — but now state persists. (A restart no longer replays from block 0; the node resumes where it stopped.)
2The .env file
/opt/gridbeasts/.env — chmod 600, owned by the service user, never committed:
# chain access
EVM_RPC_URL=https://arb1.arbitrum.io/rpc # or your provider's URL
EVM_START_BLOCK=250123456 # block where YOUR contracts were deployed
MIDNIGHT_START_BLOCK=1234567
# NTP_START_TIME=… # optional, ms epoch; else recovered from DB
# secrets
EVM_PRIVATE_KEY=0x… # batcher wallet — fund it, it pays the gas
DB_HOST=127.0.0.1
DB_PORT=5432
DB_NAME=gridbeasts
DB_USER=gridbeasts
DB_PW=… # PGLITE stays UNSET in production
EVM_START_BLOCK = the block your contracts were deployed at, not 0 — or your first
sync crawls the chain's entire history. Same logic for MIDNIGHT_START_BLOCK.
And the batcher key is a hot wallet: fund it modestly, monitor it, rotate it if leaked.
3Config changes
config.mainnet.ts is the env-driven twin of config.dev.ts. It refuses to
start without EVM_RPC_URL and the start blocks — fail-fast beats syncing the wrong chain.
You edit it for the things that are facts about your deployment:
- the real network (swap the
viemchain object, e.g.arbitrum); - your deployed contract addresses (ERC-721, EffectstreamL2, the Midnight contract) — in dev these came from ignition's deploy files, in production they're constants you paste in;
- the same three primitives you built — unchanged except addresses and start heights.
4Two systemd units
/etc/systemd/system/gridbeasts-node.service:
[Unit]
Description=GridBeasts sync node
After=network-online.target postgresql.service
[Service]
User=gridbeasts
WorkingDirectory=/opt/gridbeasts/app
EnvironmentFile=/opt/gridbeasts/.env
ExecStart=/usr/local/bin/bun run packages/node/main.mainnet.ts
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
gridbeasts-batcher.service is identical except
ExecStart=… packages/batcher/batcher.mainnet.ts and After=… gridbeasts-node.service. Then:
sudo systemctl enable --now gridbeasts-node gridbeasts-batcher
journalctl -u gridbeasts-node -f # your new `orchestrator logs sync`
5Static frontend
bun run --filter @evm-midnight/frontend build:mainnet # → packages/frontend/dist
cp game.html packages/frontend/dist/ # our game rides along
Edit the CONFIG block at the top of game.html: API and
BATCHER become your domain (https://game.example.com/api …),
ERC721 and CHAIN_ID become the real deployment. Copy dist/
to /opt/gridbeasts/www — it's just files; any CDN works too.
6nginx reverse proxy
server {
listen 443 ssl;
server_name game.example.com;
# certbot manages the certs
root /opt/gridbeasts/www; # static frontend
location /api/ {
proxy_pass http://127.0.0.1:9999; # sync node (read-only API)
}
location /batcher/ {
proxy_pass http://127.0.0.1:3334/; # note trailing slash: strips the prefix
}
}
One domain, TLS in one place, and ports 9999/3334 never face the internet —
firewall everything except 443 (and your SSH). The batcher URL players sign against is now
https://game.example.com/batcher.
curl -s https://game.example.com/api/cards # [] or your cards
systemctl status gridbeasts-node gridbeasts-batcher
journalctl -u gridbeasts-node | grep MIGRATION # migrations applied against real Postgres
Then the real test: restart the server. systemd brings both processes back,
Postgres still has the state, and the node resumes syncing from where it stopped.