Source constraint: All information in this skill is sourced from the Walrus Sites documentation and the MystenLabs/walrus-sites repository. When extending this skill, only pull from these sources.
Walrus Sites lets you deploy a static website (HTML/CSS/JS) to Walrus, with an on-chain Sui object tracking the site's resources. A portal server resolves site subdomains, fetches resources from Walrus, and serves them over HTTP. The result is a fully decentralized frontend with no centralized server, CDN, or DNS required.
Common failures:
- Blobs expire. Sites published with too few epochs silently break when blobs expire. Always use a generous
--epochsvalue or--epochs max(53 epochs maximum — about 53 days on testnet, ~2 years on mainnet). Note:--permanentonly makes a blob non-deletable; it does not extend its storage duration beyond the--epochsvalue. - Portal misconfiguration. The portal's
original_package_idmust match the Walrus Sites framework package (site::Site), not your app's Move package. - Testnet sites cannot use
wal.app. The publicwal.appportal serves mainnet only. Testnet requires a self-hosted local portal. - SPA routing. Single-page apps need
ws-resources.jsonrouting config for client-side routing. Without it, direct navigation to deep links returns 404.
Sub-skills
publishing — Building and deploying sites
Path: publishing/SKILL.md
Load when: deploying a new site, updating an existing site, choosing epoch duration, configuring ws-resources.json, or troubleshooting site-builder errors.
Covers: site-builder deploy, site-builder publish, site-builder update, --epochs flag, blob expiration, ws-resources.json configuration, SPA routing, site naming, site-builder sitemap for debugging, site-builder destroy.
portal — Running a local portal for testnet
Path: portal/SKILL.md
Load when: the user needs to view a testnet site, set up the local portal, run their own portal as a decentralized fallback, fix portal 404s, or understand how portal resolution works.
Covers: cloning the portal, installing dependencies (Bun), portal-config.yaml, the original_package_id gotcha, starting the server, URL format, port conflicts, mainnet vs testnet differences.
Routing guide
| Task | Load |
|---|---|
| Deploy a frontend to Walrus Sites | publishing/ |
| Update an existing deployed site | publishing/ |
| Fix 404 or expired site | publishing/ + portal/ |
| View a testnet site locally | portal/ |
| Set up the local portal | portal/ |
| Portal shows "Page not found" | portal/ + publishing/ (check blob expiry) |
| Configure SPA routing or custom headers | publishing/ |
| Set up custom domain (SuiNS) | Skill Content below |
| Set up CI/CD with GitHub Actions | Skill Content below |
| Check what resources a site has | publishing/ (site-builder sitemap) |
| Delete a deployed site | publishing/ |
| Choose between Walrus Sites and traditional hosting | Skill Content below |
Skill Content
Key concepts
-
Site object. A Sui object of type
site::Sitefrom the Walrus Sites framework package. It holds metadata and dynamic fields mapping resource paths to Walrus blob references. Created bysite-builder deploy, owned by the publisher's address. -
Resources. Each file in the deployed directory becomes a resource stored on Walrus. Small files might be batched into a quilt. Each resource has a path (for example,
/index.html,/assets/main.js) and optional HTTP headers. -
Portal. A server that maps
<base36-site-id>.localhost:3000or<name>.localhost:3000(self-hosted/testnet) or<name>.wal.app(mainnet with SuiNS) URLs to on-chain site objects. It reads the site's dynamic fields to find the requested resource path, fetches the blob from a Walrus aggregator, and returns it as an HTTP response. -
ws-resources.json. A configuration file in the site's root directory controlling resource headers, routing rules, redirect configuration, metadata, and file ignoring. Auto-generated on first deploy with the site object ID. On subsequent deploys, the site-builder reads it to determine whether to create a new site or update the existing one. -
Blob expiration. Walrus blobs have a finite storage duration (maximum 53 epochs). When blobs expire, the portal returns a distinct 404 whose body reads "This content is no longer available / It may have expired" and names the Blob ID — this is different from a regular page-not-found 404. Use
site-builder sitemap <object-id>to check expiration dates. Re-deploy with a higher--epochsvalue to fix. -
site-builder deploy. The recommended unified command that creates a new site or updates an existing one (replacing the legacypublishandupdatecommands).
Site-builder CLI commands
| Command | Purpose |
|---|---|
site-builder deploy <DIR> |
Create or update a site (recommended) |
site-builder sitemap <OBJECT_ID> |
List resources with expiry dates |
site-builder list-directory <DIR> |
Preview what would be uploaded |
site-builder update-resources |
Add or update one or more resources |
site-builder destroy <OBJECT_ID> |
Remove a site permanently |
site-builder convert <HEX> |
Convert hex object ID to Base36 |
deploy updates when it finds an object ID, from --object-id <OBJECT_ID> or the object_id field in ws-resources.json, and publishes a new site otherwise. --epochs is required (max for the longest duration); --list-directory generates an index page for raw files. Run site-builder deploy --help to confirm flag spellings for your installed version; the docs' tutorial shows --object-ids while the reference page shows --object-id.
HTTP redirects
The redirects field in ws-resources.json defines server-side HTTP redirects. Rules are stored as dynamic fields on the Site object, and the portal evaluates them before fetching any resource:
{
"redirects": {
"/old-page": { "location": "/new-page", "status_code": 301 },
"/walrus-docs": { "location": "https://docs.wal.app", "status_code": 301 },
"/legacy/**/*": { "location": "/index.html", "status_code": 302 }
}
}
location is a path on the site or a full URL; status_code is typically 301, 302, or 308. Patterns are glob-style: * matches one path segment, **/* any number. This is distinct from the routes section, which is client-side routing only. The public portal caps redirect chains at a depth of 3.
Production checklist
From the docs' production guide:
- Portal:
wal.appserves only Mainnet sites that have a SuiNS name; nested subnames such asapp.myname.wal.appdo not resolve, so use one SuiNS name per site. Testnet and previews need a local or self-hosted portal. - SPA routing:
"routes": { "/*": "/index.html" }; more specific patterns win over the wildcard. - Caching: portals and CDNs cache aggressively. Use content-hashed filenames, keep the old site object when you need a rollback target, and verify a fresh deploy from a private window before concluding it failed.
- CI: there is no watch mode; run
site-builder deployfrom CI on each push. Commitws-resources.json(it carries the site object ID) or every CI run creates a new site instead of updating yours. - Next.js: build with
output: "export"andimages: { unoptimized: true }, deployout/, and putws-resources.jsoninpublic/so redeploys update the same site. API routes, server actions, and on-demand rendering do not run. - Toolchain:
suiupinstalls and pinssui,walrus, andsite-buildertogether.
Custom domains### Custom domains
SuiNS names: Associate a SuiNS name with your site object using SuiNS tooling (a separate on-chain step). The portal resolves <name>.wal.app to your site via the SuiNS name record's walrusSiteId field. Note: the site_name field in ws-resources.json only sets the Site object's on-chain display name — it does not configure SuiNS.
Custom DNS: Point your own domain to a Walrus Sites portal using DNS CNAME records. See the DNS configuration guide for details.
CI/CD with GitHub Actions
Use the official Deploy Walrus Site GitHub Action to automate deployments:
- uses: MystenLabs/walrus-sites-github-actions/deploy@v3
with:
DIST: ./dist
SUI_NETWORK: testnet
EPOCHS: 30
SUI_ADDRESS: ${{ secrets.SUI_ADDRESS }}
SUI_KEYSTORE: ${{ secrets.SUI_KEYSTORE }}
Authentication methods (choose one):
SUI_ADDRESS+SUI_KEYSTORE— address and keystore file contentsSUI_BECH32_PRIVATE_KEY— a bech32-encoded private keySUI_SECRET_PHRASE— a 12/24-word recovery phrase
The action handles building, publishing, and updating ws-resources.json. Use GITHUB_TOKEN to have the action create a PR with updated ws-resources.json after first deploy.
Known restrictions
- Static sites only. No server-side rendering, request handlers, or databases. HTTP redirects are supported through the
redirectsfield inws-resources.json; theroutessection is client-side only, and framework redirect plugins that run server-side at request time do not work. - No secrets. All content is stored on Walrus (public). Do not include API keys, credentials, or private data.
- Maximum 3 redirect hops on the public portal; self-hosted portals can configure the limit.
- Route wildcards only at the end of a pattern (
/path/*, not/path/*/to), route targets must exist among deployed files, and custom headers are per exact resource path, not global. - Service-worker portal limitations on iOS and certain browsers.
- PWAs not fully supported.
Rules
- Choose an appropriate
--epochsvalue. Maximum is 53 epochs (~53 days on testnet where 1 epoch = 1 day, ~2 years on mainnet where 1 epoch = 14 days). Use--epochs maxfor the longest possible storage duration. Note:--permanentonly prevents deletion — it does not extend storage duration. - Build before deploying. Run
npm run build(or equivalent) to produce a static output directory. The site-builder deploys whatever directory you point it at. - Do not change
original_package_idin the portal config unless you know the Walrus Sites framework package has been upgraded. wal.appserves Mainnet sites with a SuiNS name only. No Testnet sites, no nested subnames. For testnet, self-host the portal from theMystenLabs/walrus-sitesrepo.- Keep
ws-resources.jsonin version control. It records the site object ID. Without it, future deploys create a new site instead of updating the existing one. - Use
site-builder deployinstead ofpublish/update. Thedeploycommand is the recommended unified command.
Common mistakes
- Publishing with
--epochs 5and wondering why the site disappears. Blobs expired. Re-deploy with a higher--epochsvalue or--epochs max. - Changing the portal's
original_package_idto your app's package ID. The portal needs the Walrus Sites framework package ID (site::Site), not your Move contract's ID. - Trying to visit a testnet site on
wal.app. It only serves mainnet. Use a local portal for testnet. - Forgetting to build the frontend before deploying.
site-builder deploy dist/deploys thedist/directory contents. If you did not build first, you are deploying source files or stale output. - Deploying an SPA without fallback routing. React/Vue/Svelte SPAs use client-side routing. Direct navigation to
/dashboardhits the portal looking for a/dashboardresource that does not exist. Configure a fallback route inws-resources.json. - Not killing port 3000 before starting the portal. Other dev tools often use port 3000. The portal fails silently if the port is taken.
- Not committing
ws-resources.jsonafter first deploy. Without it, the next deploy creates a brand new site instead of updating the existing one. - WASM files returning 503 errors. Large
.wasmfiles can triggerupstream connect error or disconnect/reset before headersfrom the portal. This is caused by the aggregator response exceeding the portal's idle timeout, not blob size limits. The fix (commit a31ec78, #714) sizesidleTimeoutfrom the aggregator retry budget. If you hit this, tune viaAGGREGATOR_REQUEST_TIMEOUT_MS/PORTAL_IDLE_TIMEOUT_MAX_Senvironment variables. - Windows users using
$(pwd)in Docker commands. PowerShell does not support$(pwd). Use the full absolute path instead. - SuiNS domain costs. Custom
.suinames cost approximately $10/year through SuiNS. This is separate from Walrus storage costs. - Deploying the repo root instead of the build output. Point
site-builder deployatdist/,build/, orout/— not the repository root. Deploying the root uploadsnode_modules/, source files, and config, dramatically increasing costs. - Redeploying to an existing site without
ws-resources.json. If you lost the file, use--object-id <SITE_OBJECT_ID>to target the existing site object directly.