Skip to content

Production Mado 0.15.3

Deployment

One command. One artifact. Many hosts. mado release writes everything a static host needs into out/. The same out/ 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 .gz files written by mado 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.html suppresses that generated catch-all, allowing Pages to serve 404.html for unknown URLs.
  • The generated _headers (immutable cache for /assets/*, no-cache for 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: _redirects is 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 ignores no-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 during mado release. If you bypassed build and shipped your own unhashed JS, give it a hash or short cache.
  • Captured snapshot shows [object Object]. mado static rejects non-JSON-serialisable seeds at build time and points at the bad field. If you still see it, upgrade @madojs/mado and re-run mado 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:url include the prefix;
  • mado preview redirects 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-404 try_files variant above.
  • Cloudflare Pages / Netlify — the default _redirects SPA 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 the gh-pages deploy path; canonical URLs land at https://<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.