Stablecoin Checkout docs
v1.0.0
Live demoOperator console demo Get help
● Ship · Production

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

PathGood forVerified
Linux + systemd + Caddy (recommended)Production on one small VPSRun locally against public devnet; systemd units not run on a real Linux host
PM2Hosts where you do not want systemd unitsPM2 start verified
Docker ComposeHosts that already run DockerNot verified docker build/compose not run; the Dockerfile steps were replayed on a clean archive
Static apps on Vercel, server elsewhereStatic apps on a CDN; server on Fly.io or Render with a volumeNot 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.

  1. DNS and firewallA/AAAA records for pay.example.com (and optionally admin.example.com and the landing domain) pointing at the server. Allow 22, 80 and 443 (plus 443/udp). Do not expose 8787.
  2. Install Node 22 and CaddyFrom nodejs.org or NodeSource, and caddyserver.com/docs/install. Then sudo corepack enable.
  3. Code and build
    sudo 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:web
    With Base wallet pay, export VITE_BASE_ROUTER_ADDRESS=<router> before build:web.
  4. Configuration
    sudo 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.env
    It prints the operator key once. Both files stay root:root 0600; systemd reads them as root.
  5. Service
    sudo 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
    At run time the service is read-only on /opt (ProtectSystem=strict); only /var/lib/mikodes-checkout is writable.
  6. Caddy
    sudo 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
    Caddy obtains and renews the HTTPS certificates, streams server-sent events unbuffered and blocks /v1/operator, /admin and /ops on the checkout domain.
  7. 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
Product pages

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 the server-data volume 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 the caddy-data volume.
  • 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 needs up -d --build web.
Not run by MIKODES

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.

Operator API exposure

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.

Tested, not deployed

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.