Production Mado 0.15.3
Deployment
One command. One artifact. Many hosts.
mado releasewrites everything a static host needs intoout/. The sameout/can be pushed to nginx, Cloudflare Pages, Netlify, S3 or GitHub Pages without re-building.
What mado release produces
out/
├── .mado-output ← path-free Mado ownership marker
├── index.html ← captured snapshot for / (or SPA shell)
├── assets/ ← Vite hashed assets
│ ├── *.gz ← precompressed gzip (gzip_static / Accept-Encoding)
│ └── *.br ← precompressed brotli (brotli_static / Accept-Encoding)
├── <route>/index.html ← captured snapshot per static route
├── 404.html ← captured static wildcard or noindex SPA shell
├── _mado/spa.html ← SPA fallback shell (noindex)
├── sitemap.xml ← generated sitemap
├── favicon.svg ← your public/ assets copied verbatim
├── _redirects ← optional Cloudflare Pages / Netlify policy
└── _headers ← Cloudflare Pages / Netlify cache rules
.mado-output lets a later release clean only an artifact previously claimed
by Mado. Its contents are limited to { "owner": "@madojs/mado" }; it never
records the build machine's project path.
_headers is generated when the project does not provide one. _redirects
is generated with the SPA catch-all only when release did not already produce
an explicit host fallback. A captured wildcard or public/404.html therefore
keeps unknown URLs on the host's real 404 path. A user-authored _redirects
always wins.
Local rehearsal
mado release
mado preview # http://localhost:4173 — rehearses Mado's output policy
mado preview picks .br over .gz over raw and serves captured route files
first. For an unknown document it follows Mado's release policy: the generated
SPA catch-all serves /_mado/spa.html with 200, while an explicit host fallback
serves 404.html with 404. Arbitrary provider-specific redirect files are not
fully emulated; use the provider's local emulator for custom hybrid rules.
Recipe 1: VPS + nginx
The framework ships an optional nginx recipe in
docs/recipes/nginx: nginx.conf for static hosting and a
Containerfile you can copy into generated apps. Drop out/ into the host and
point nginx at it.
# Build the artifact locally
mado release
# Upload to the VPS
rsync -avz --delete out/ user@server:/var/www/myapp/
# On the VPS — first time only:
sudo cp /etc/nginx/conf.d/myapp.conf{,.bak}
sudo cp ./docs/recipes/nginx/nginx.conf /etc/nginx/conf.d/myapp.conf
sudo nginx -t && sudo systemctl reload nginx
Key lines of the recipe nginx.conf:
gzip_static on;— serves the precompressed.gzfiles written bymado release. Zero CPU at request time./assets/*should be cached immutable; Vite filenames are content hashed.try_files $uri $uri/ /_mado/spa.html;— SPA fallback so deep links work after a hard refresh.
That shipped recipe intentionally models the universal starter's mixed static/SPA policy. For an all-static site with a literal static wildcard, serve the captured host fallback instead:
error_page 404 /404.html;
location = /404.html { internal; }
location / { try_files $uri $uri/ =404; }
A hybrid app that also wants a real host 404 must add explicit rewrite
locations for each known SPA-only URL family before that final location /.
Enable HTTPS with Let's Encrypt / Certbot. Add HSTS once you have it.
Recipe 2: Cloudflare Pages
mado release
npx wrangler pages deploy out --project-name=myapp
- Without an explicit host 404, the generated
_redirects(/* /_mado/spa.html 200) gives you SPA fallback. - A literal static wildcard or
public/404.htmlsuppresses that generated catch-all, allowing Pages to serve404.htmlfor unknown URLs. - The generated
_headers(immutable cache for/assets/*,no-cachefor HTML) is honored by CF Pages. - Captured routes are promoted to real route files (
out/<route>/index.html), so direct requests receive the captured document.
For a hybrid application, commit your own public/_redirects: rewrite known
SPA route families to /_mado/spa.html, and do not add a final /* rule if
unknown URLs should reach 404.html.
For preview branches, set the same build command in the CF Pages project:
Build command: npm ci && npx mado release
Output directory: out
For catalogs too large to capture within the release budget, keep the affected routes SPA-only or reduce the declared static path set. Mado does not provide an on-demand snapshot service.
Recipe 3: Static-only hosts (S3, Netlify, GitHub Pages)
The out/ files are portable, but SPA and hybrid request routing remains
host-specific. Choose a host whose rewrite/404 policy can represent the route
mix described above:
Netlify
mado release
npx netlify deploy --prod --dir=out
_redirects and _headers are recognized natively.
S3 / CloudFront
mado release
aws s3 sync out/ s3://my-bucket/ --delete \
--cache-control "public, max-age=31536000, immutable" --exclude '*.html'
aws s3 sync out/ s3://my-bucket/ \
--cache-control "no-cache, must-revalidate" --include '*.html'
Configure CloudFront's "Default root object" to index.html. For the SPA
policy, map 403/404 to /_mado/spa.html with status 200. For an all-static
site with an explicit host fallback, map errors to /404.html and preserve
status 404.
GitHub Pages
mado release
# Push out/ into the gh-pages branch (or use actions/upload-pages-artifact)
Pages handles route index.html files automatically. If the manifest's global
"*" page declares literal static: true, mado release captures its
pathname-independent UI into 404.html, forces noindex, and excludes it from
the sitemap. Otherwise 404.html remains a copy of the noindex SPA shell for
client-only routes. A user-supplied public/404.html still wins.
GitHub Pages cannot express targeted rewrites for known SPA-only routes. For a mixed application, keep the wildcard non-static so hard refreshes use the SPA shell. Reserve the static wildcard for an all-static Pages deployment.
Cache-control matrix
| Path | Cache-Control | Why |
|---|---|---|
/assets/main-*.js |
public, max-age=31536000, immutable |
hashed filename → never reuse |
/assets/chunk-*.js |
public, max-age=31536000, immutable |
same |
/*.html |
no-cache, must-revalidate |
always reflect latest deploy |
| Other static files | public, max-age=86400 |
safe daily cache |
mado release writes these rules into out/_headers for CF / Netlify and
the nginx recipe enforces them server-side.
CI sketch (GitHub Actions)
# .github/workflows/release.yml
name: release
on:
push:
branches: [main]
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22 }
- run: npm ci
- run: npx mado release
- uses: actions/upload-artifact@v4
with:
name: out
path: out
retention-days: 7
# Pick one deploy step:
# - run: rsync -avz out/ user@server:/var/www/myapp/
# - run: npx wrangler pages deploy out --project-name=myapp
# - run: npx netlify deploy --prod --dir=out
Troubleshooting
- 404 on hard refresh of a deep link. Your host did not pick up SPA
fallback. nginx: check
try_files. CF/Netlify:_redirectsis present? S3+CloudFront: configure the 404 →/_mado/spa.html(200) error response. - A known SPA route receives the captured 404. The app opted into a
static wildcard but the host has no targeted rewrite for that route. Add an
explicit provider rule to
/_mado/spa.html, or keep*non-static. - HTML is cached forever. Either your host sent a default
Cache-Control: public, max-age=...or you are sitting behind a CDN that ignoresno-cache. Add an explicit rule mirroring the matrix above. /assets/*files change but the browser keeps the old one. They should not — the filename is hashed by Vite duringmado release. If you bypassed build and shipped your own unhashed JS, give it a hash or short cache.- Captured snapshot shows
[object Object].mado staticrejects non-JSON-serialisable seeds at build time and points at the bad field. If you still see it, upgrade@madojs/madoand re-runmado release.
CSP and trusted content
Mado serialises JSON-LD and static seed JSON with <, U+2028 and U+2029
escaped, so data cannot close its script element. unsafeHTML() is different:
it is an explicit trust boundary and must receive only audited or sanitised
markup.
Start with a restrictive policy such as default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; object-src 'none'; base-uri 'self'
and tighten styles with nonces/hashes when your deployment supports them.
Trusted Types can enforce the same application boundary around any code that
produces trusted HTML. The devtools overlay is development-only and should not
be imported in production entrypoints.
Sub-path deployments and base
A deployment under a sub-path (https://your.site/docs/, GitHub
Pages project URL, an admin panel mounted under /admin/, …) only
requires two changes:
// vite.config.ts
import { defineConfig } from "vite";
import { mado } from "@madojs/mado/vite";
export default defineConfig({
base: "/docs/", // ← active prefix
plugins: [mado({ site: "https://your.site" })],
});
Then make every internal anchor go through routeUrl() + data-link
so the URL is correct under any base:
import { html, routeUrl } from "@madojs/mado";
html`<a data-link href=${routeUrl("/")}>Home</a>`; // → "/docs/"
html`<a data-link href=${routeUrl("/guides/intro")}>Intro</a>`;
mado release honours base end-to-end:
- captured snapshots load assets through
/docs/assets/...; - sitemap URLs are
https://your.site/docs/...; - canonicals and
og:urlinclude the prefix; mado previewredirects bare/to/docs/and applies the artifact's SPA-or-404 policy under/docs/<anything>.
On the production host:
- nginx — mount under the matching prefix (
location /docs/ { ... }) and choose the SPA or host-404try_filesvariant above. - Cloudflare Pages / Netlify — the default
_redirectsSPA rule uses/_mado/spa.html; a static wildcard suppresses that rule. Custom hybrid rewrites remain user-owned. - GitHub Pages (project site) — set
base: "/<repo>/"and use thegh-pagesdeploy path; canonical URLs land athttps://<user>.github.io/<repo>/....
See also: 16-app-architecture.md for the
src/ / public/ / out/ model and
15-static-snapshots.md for the static
snapshot mechanics.