Deployment
on a server you control.
Four ways to run it in production, the Base router, and a public testnet demo. You run every part on infrastructure you pay for; MIKODES does not host instances.
01Pick a path
| Path | Good for | Verified |
|---|---|---|
| Linux + systemd + Caddy (recommended) | Production on one small VPS | Run locally against public devnet; systemd units not run on a real Linux host |
| PM2 | Hosts where you do not want systemd units | PM2 start verified |
| Docker Compose | Hosts that already run Docker | Not verified docker build/compose not run; the Dockerfile steps were replayed on a clean archive |
| Static apps on Vercel, server elsewhere | Static apps on a CDN; server on Fly.io or Render with a volume | Not verified configs in deploy/vercel/ never deployed |
Whatever you choose: one server process per database, a persistent disk, and the operator API reachable only where you intend.
browser ─ https ─> Caddy ─┬─ pay.example.com / → checkout (static)
│ /v1 /public /health /demo/invoices → server
├─ admin.example.com /admin /ops → server (console)
│ /v1 /public /health → server (operator API only here)
│ / → interim merchant portal (static)
└─ example.com / → landing (static, optional)
server (Node, 127.0.0.1:8787) ─ SQLite on disk
└─ reads chains through YOUR RPC URLs
02Linux + systemd + Caddy
Layout the units expect: code in /opt/mikodes-checkout, data in /var/lib/mikodes-checkout, configuration in /etc/mikodes-checkout, service user mikodes.
- DNS and firewallA/AAAA records for
pay.example.com(and optionallyadmin.example.comand the landing domain) pointing at the server. Allow 22, 80 and 443 (plus 443/udp). Do not expose 8787. - Install Node 22 and CaddyFrom nodejs.org or NodeSource, and caddyserver.com/docs/install. Then
sudo corepack enable. - Code and build
With Base wallet pay, exportsudo useradd --system --home-dir /opt/mikodes-checkout --shell /usr/sbin/nologin mikodes sudo mkdir -p /opt/mikodes-checkout && sudo chown mikodes:mikodes /opt/mikodes-checkout # copy the unzipped package there, then: cd /opt/mikodes-checkout sudo -u mikodes -H pnpm install --frozen-lockfile sudo -u mikodes -H pnpm --filter "@mikodes-checkout/server..." build sudo -u mikodes -H pnpm --filter @mikodes-checkout/ops build sudo -u mikodes -H pnpm run build:webVITE_BASE_ROUTER_ADDRESS=<router>beforebuild:web. - Configuration
It prints the operator key once. Both files staysudo install -d -m 0700 -o root -g root /etc/mikodes-checkout sudo node scripts/setup.mjs --target bare \ --server-env /etc/mikodes-checkout/server.env \ --caddy-env /etc/mikodes-checkout/caddy.envroot:root 0600; systemd reads them as root. - Service
At run time the service is read-only onsudo cp deploy/systemd/mikodes-checkout.service /etc/systemd/system/ sudo systemctl daemon-reload sudo systemctl enable --now mikodes-checkout curl -s http://127.0.0.1:8787/health journalctl -u mikodes-checkout -f # start-up warnings, and the console setup code/opt(ProtectSystem=strict); only/var/lib/mikodes-checkoutis writable. - Caddy
Caddy obtains and renews the HTTPS certificates, streams server-sent events unbuffered and blockssudo cp deploy/caddy/Caddyfile /etc/caddy/Caddyfile sudo systemctl edit caddy # add: [Service] EnvironmentFile=/etc/mikodes-checkout/caddy.env sudo systemctl restart caddy curl -s https://pay.example.com/health/v1/operator,/adminand/opson the checkout domain. - Backups
sudo install -d -o mikodes -g mikodes -m 0700 /var/backups/mikodes-checkout sudo cp deploy/systemd/mikodes-backup.{service,timer} /etc/systemd/system/ sudo systemctl daemon-reload && sudo systemctl enable --now mikodes-backup.timer
The shipped docs build only the server with --filter "@mikodes-checkout/server...". The console's product pages (/ops) need apps/ops/dist, so build @mikodes-checkout/ops too (step 3), or run pnpm run build. The Docker image already contains them.
03PM2 instead of systemd
pnpm install && pnpm run build
pnpm run setup -- --target bare --server-env apps/server/.env --data-dir /var/lib/mikodes-checkout
npx pm2 start deploy/pm2/ecosystem.config.cjs # server + daily backup
npx pm2 save && npx pm2 startup # then run the command it prints
One instance in fork mode, never cluster mode. Node reads the env file itself, so PM2 never stores your secrets. Put Caddy (or another proxy that terminates TLS and streams SSE) in front with API_UPSTREAM=127.0.0.1:<PORT> and WEB_ROOT pointing at the build:web output.
04Docker Compose
pnpm run setup -- --target docker # writes deploy/.env (0600)
docker compose -f deploy/docker-compose.yml up -d --build
docker compose -f deploy/docker-compose.yml ps # server should become "healthy"
docker compose -f deploy/docker-compose.yml logs -f server
server:node:22-bookworm-slim, non-root, read-only root filesystem, SQLite on theserver-datavolume at/data, health check on/health, not published to the host. It carries the console and product pages.web: Caddy with the built static apps, publishes 80/443; certificates in thecaddy-datavolume.- With
ADMIN_ALLOW_IPS, Caddy must see real client IPs; a CDN in front replaces them. Test from an address outside the list and confirm you get 403. - Changing any
VITE_*value needsup -d --build web.
The Docker path was not run with Docker itself. Test it on a staging host before production.
05Static apps on Vercel, server elsewhere
The server needs a long-running process and a persistent disk, so it cannot run on Vercel. Run it on a VPS or on Fly.io/Render with deploy/Dockerfile.server, a volume at /data, one instance and the env vars from deploy/.env.example as platform secrets. Then deploy the static apps with the configs in deploy/vercel/ (for example vercel --local-config deploy/vercel/checkout.vercel.json), with VITE_API_URL=https://api.example.com. On the server set CORS_ORIGINS and PUBLIC_CHECKOUT_URL.
With the apps on another origin, the Caddy rule that hides /v1/operator from the public does not apply. Restrict it at your platform if it can filter by IP, and keep the operator key long and secret.
06Base router
Base wallet pay uses MikodesPayRouter. Deploy it once per chain from your own machine; the deployer key lives only in that shell for the duration of the command.
pnpm --filter @mikodes-checkout/contracts-evm build
cd contracts/evm
node scripts/deploy.mjs --network base-sepolia # 1. predicted address, no key
RPC_URL=https://<your Base Sepolia RPC> node scripts/deploy.mjs --network base-sepolia # 2. dry run
read -rs DEPLOYER_PRIVATE_KEY && export DEPLOYER_PRIVATE_KEY # 3. fresh wallet with a little ETH for gas
RPC_URL=https://<your Base Sepolia RPC> node scripts/deploy.mjs --network base-sepolia --broadcast
unset DEPLOYER_PRIVATE_KEY
For Base mainnet repeat with --network base. The address comes from CREATE2: always use the address your run prints and check it on the explorer. Then set BASE_RPC_URL, BASE_CHAIN, BASE_ROUTER_ADDRESS and OPERATOR_BASE_ADDRESS on the server, VITE_BASE_ROUTER_ADDRESS for the checkout build, restart and rebuild. GET /health lists Base once it is enabled.
The router has contract tests and a keyless dry run. No deployment has been broadcast from this repository, and Base refund signing has not been exercised on a chain.
07A public testnet demo
For a buyer-clickable demo, run a separate instance on Solana devnet with DEMO_MODE='true', SOLANA_CHAIN='solana-devnet', your devnet fee wallet and DEMO_MERCHANT_SOLANA_ADDRESS, then seed it:
sudo node --env-file=/etc/mikodes-checkout/server.env scripts/demo-reset.mjs seed --credit 50
deploy/systemd/mikodes-demo-reset.{service,timer} resets it nightly (04:05 UTC). The wipe refuses to run unless DEMO_MODE is true, every chain is a testnet, --yes-wipe-demo is given and every merchant is a demo merchant. Keep the demo on its own host and database, keep "demo" in its host name, and never show demo numbers as performance.