Skip to content

Deployment

The repo has three parts that are tested and deployed independently: contracts, indexer, and web. Each part has its own CI job and its own deployment path.

CI

.github/workflows/ci.yml runs on every push and on workflow_dispatch. Each part is its own job so that when something goes red, the job name says immediately where to look.

JobSteps
contractsCheckout with submodules → Foundry toolchain → forge test -vv
indexerNode 24 → npm cinpx tsc --noEmitnpm test
webNode 24 → npm cinpx tsc --noEmitnpm testnpm run build

The contracts job sets MONAD_TESTNET_RPC=https://testnet-rpc.monad.xyz because foundry.toml declares this variable; no test forks, but a missing variable is an unnecessary way to fail.

For web, the build is also a test: type errors or missing modules turn CI red instead of surfacing in front of someone opening the site.

CD — web

.github/workflows/deploy-web.yml runs on a push to main with changes in web/**, contracts/src/algorithms/**, infra/deploy-web.sh, infra/rewrite-profile.js, infra/deploy-web-function.sh, or the workflow itself.

The runner holds no credentials. It requests an OIDC token from GitHub, AWS exchanges the token for a short-lived session, and the role's trust policy names exactly this repo:

role-to-assume: arn:aws:iam::962761391641:role/mochi-deploy-web
S3_BUCKET: mochi-meme-web
CLOUDFRONT_DISTRIBUTION_ID: E1RY7HHFKRVFBJ

The steps:

  1. infra/deploy-web.sh — build the bundle, sync to S3, invalidate CloudFront.
  2. infra/deploy-web-function.sh — publish the CloudFront Function mochi-profile-rewrite with GATE_SECRET taken from GitHub secrets, so the secret does not sit in the repo. This step also invalidates the distribution.
  3. Verify assets: read the list of /_next/static/... in web/out/index.html then curl each one until all return 200.

Concurrency group deploy-web, no cancel-in-progress: a killed run can leave the bucket holding half of one bundle and half of another.

A 200 status proves nothing

S3 has no directory index, so a nonexistent path still returns 200 with the home page. The verification step therefore reads the served bytes, not the status code.

Docs on Cloudflare Pages

VitePress lives in web/docs and is not part of the web bundle. It is a separate build, hosted on Cloudflare Pages at docs.mochi.meme. Pages was chosen over CloudFront because the zone is already on Cloudflare: Pages issues a certificate for the custom domain automatically, so there is no ACM to create or CloudFront alias to add.

The Pages project is a direct upload type: Cloudflare's Git integration requires the Pages GitHub app on the repo, which this repo does not have, so .github/workflows/deploy-docs.yml builds and pushes the output with wrangler whenever a push to main changes web/docs/** (or web/package.json). The deploy step needs the CLOUDFLARE_API_TOKEN secret with Cloudflare Pages: Edit permission; without the secret the deploy step is skipped and the workflow stays green.

The old /docs URL on the app is permanently redirected to the new host by a CloudFront Function. See System architecture.

CD — indexer

.github/workflows/deploy-indexer.yml runs on a push to main with changes in indexer/** or infra/deploy-indexer.sh.

The VPS has no cloud API to authenticate against, so this is SSH rather than OIDC. The repo holds a dedicated deploy key in secrets (VPS_SSH_KEY, VPS_HOST, VPS_USER, VPS_PORT), created for exactly this workflow and added to the deploy user's authorized_keys. It is not the machine owner's personal key.

The infra/deploy-indexer.sh script:

  1. rsync the source to the host. .env and mochi.db* are excluded — the first holds the gate secret and the Pinata key, the latter is indexed state and takes hours to rebuild.
  2. npm ci --omit=dev under the host's Node 22 (/opt/mochi/node).
  3. Verify node_modules/.bin/tsx exists before restarting.
  4. sudo systemctl restart mochi-indexer.
  5. Wait for localhost:8787/health to answer.

Concurrency group deploy-indexer, no cancel-in-progress: the script rsyncs before restarting, so two overlapping runs can leave the host carrying the source from one run and the process from another.

Manual commands

Contracts

Full deploy, including Identity and Community:

bash
cd contracts
set -a && . ./.env && set +a
forge script script/Deploy.s.sol:Deploy \
  --rpc-url "$MONAD_TESTNET_RPC" --private-key "$PRIVATE_KEY" --broadcast

Redeploy only posts and the sorts, keeping handles and communities:

bash
forge script script/RedeployPosts.s.sol:RedeployPosts \
  --rpc-url "$MONAD_TESTNET_RPC" --private-key "$PRIVATE_KEY" --broadcast

Indexer

bash
./infra/deploy-indexer.sh

INDEXER_HOST defaults to ninjaverse-vps, INDEXER_DIR defaults to /opt/mochi/indexer.

Web

bash
export S3_BUCKET=mochi-meme-web
export CLOUDFRONT_DISTRIBUTION_ID=E1RY7HHFKRVFBJ
./infra/deploy-web.sh

Publishing the edge function with the gate (if needed) lives in infra/deploy-web-function.sh; the GATE_SECRET variable is never written to any file in the repo.

Deployed contract addresses

Chain id 10143, last redeploy 2026-09-18, explorer https://testnet.monadscan.com.

ContractAddressBlock
IdentityRegistry0xa67ef35974bc8874318d249b6e74c7bd5870d1db63285812
CommunityRegistry0x41c19889a3218000482a1cd6e13640edcd16cf3263285812
PostRegistry0xaC74A6AF7Ec96e28F2885916673EF3B9aACF10F563379890
ChronoFeed0xF9CC631A04C5398Be97d5410303Cd61bD56d1a7963379890
HotFeed0xAF50B9FE82E42a7fCb9B7a31BC1DDB9dd02a661d63379890
BestFeed0x383C7Af1CA32f72f916a14bb03f95ec1D98A14E663379890
ControversialFeed0x6A94E5a83102b04c5965F8A756e2cB84c6B7170c63379890
AlgorithmRegistry0xeEC08F90CFe669e3366933cF1C72fe4f08A3F28963379890

Identity and Community were deployed by script/Deploy.s.sol at block 63285812. The karma-aware half (posts and the sorts) was redeployed by script/RedeployPosts.s.sol at block 63379890, keeping Identity and Community unchanged. The default feed slot is HotFeed; BestFeed holds the second slot. Four algorithms are registered. No contract has a governance function. Full addresses and function references are in Contracts and addresses.

Never put secrets in the docs

This page contains no gate code, private key, or SSH key. Contract addresses are public data; the deployer's private key never sits in the repo.

Documentation for Monad testnet. It describes what is actually running.