- name
- static-site
- description
- Deploy a frontend or any static site to the IC with the @dfinity/static-site recipe (the certified-assets canister). Covers icp.yaml recipe config, SPA routing with _redirects, custom headers/CSP with _headers, clean URLs, access protection for private apps, custom domains, and building against canister IDs. This is the recommended way to host frontends and static files on the IC. Also the entry point for the legacy @dfinity/asset-canister recipe and .ic-assets.json5 (see the legacy reference) and for migrating an existing asset canister to certified-assets. Use when hosting a frontend, deploying static files, an asset canister, or setting up SPA routing on the IC. Do NOT use for canister-level HTTP code patterns or custom domain DNS setup — use custom-domains for DNS.
- license
- Apache-2.0
- compatibility
- icp-cli >= 1.0.0, Node.js >= 22
- metadata
- {"title":"Static Site (Certified Assets)","category":"Frontend"}
# Static Site (Certified Assets)
## What This Is
The **`@dfinity/static-site` recipe** deploys a static site — a built frontend, docs, or any folder of files — to the **certified-assets canister** on the Internet Computer, which serves it over HTTP with **response certification**. Every response carries a cryptographic proof, and the IC HTTP gateway verifies that proof before handing the response to the browser: visitors get content the canister provably committed to, not something a boundary node or gateway altered in transit.
**This is the recommended way to host a frontend on the IC going forward.** The recipe bundles a matched pair — the canister and its sync plugin — pinned together by one version. You point it at your build directory; `icp deploy` uploads, certifies, and serves.
> The older **`@dfinity/asset-canister` recipe** (the SDK asset canister, configured with `.ic-assets.json5`) is still supported for existing projects but is no longer the recommended path. It is a *different canister* with a different config format and API — see [`references/legacy-asset-canister.md`](references/legacy-asset-canister.md). To move an existing project over, see [`references/migrating-from-asset-canister.md`](references/migrating-from-asset-canister.md).
## Prerequisites
- `icp-cli` — `npm install -g @icp-sdk/icp-cli`. The recipe pins the canister + sync-plugin pair, both pre-built, so a plain `dir`/`build`/`presync` deploy needs nothing else.
- `ic-wasm` — `npm install -g @icp-sdk/ic-wasm`. A **separate** binary, not bundled with `icp-cli`. This recipe shells out to it *only* when you set the `metadata` field (the generated build guards on `command -v ic-wasm` and fails with "ic-wasm not found"); other official recipes need it unconditionally, so installing both up front is the safe default — see the `icp-cli` skill.
- Your frontend's build toolchain (e.g. Node.js >= 22 for a Vite/React app).
## Canister IDs and URLs
Static-site canisters are created per-project — there is no global canister ID. After deployment the canister ID is stored in `.icp/<cache|data>/mappings/<environment>.ids.json`. Managed networks (the local replica) are **cache** — `.icp/cache/mappings/local.ids.json`; connected networks (mainnet `ic`) are **data** — `.icp/data/mappings/ic.ids.json`.
| Environment | Browser URL |
|-------------|-------------|
| Local | `http://<canister-name>.<environment>.localhost:8000` — the middle label is the **environment name**, `local` by default, so `http://frontend.local.localhost:8000` (this is the URL `icp deploy` prints; `http://<canister-id>.localhost:8000` also works — `<canister-name>.localhost` with no environment label does not) |
| Mainnet | `https://<canister-id>.icp.net` |
| Custom domain | `https://yourdomain.com` (with DNS configuration) |
## icp.yaml Configuration
```yaml
canisters:
- name: frontend
recipe:
type: "@dfinity/static-site@v0.3.3"
configuration:
build:
- npm ci
- npm run build
dir: dist
```
Check the [static-site releases](https://github.com/dfinity/icp-cli-recipes/releases?q=static-site) for the latest version and pin it in the `type` field. Because the recipe pins a matched canister + plugin pair, there is **no separate canister version to choose** — the recipe version *is* the canister version.
The recipe takes four configuration fields:
| Field | Required | Description |
|-------|----------|-------------|
| `dir` | **Yes** | The single directory of built files to serve. The canister owns its whole URL space, so this is one directory, not a list. Vite → `dist`, Next.js export → `out`. |
| `build` | No | Shell commands run *before the canister exists* to produce `dir` (e.g. `npm run build`). No canister IDs are available yet. |
| `presync` | No | Shell commands run *at sync time, after the canister exists*, with deployed canister IDs exported as env vars. Use this to bake a canister ID into a frontend build (see [below](#building-against-canister-ids-presync-vs-build)). |
| `metadata` | No | `name`/`value`/`visibility` entries baked into the canister wasm via `ic-wasm`. `visibility` is optional, `public` or `private`; omitted means `private` (ic-wasm's default), and only `public` sections are readable by anyone via `icp canister metadata`. Values are interpolated in a shell at build time, so `$(…)` works. |
## Pitfalls
1. **Using `.ic-assets.json5` with the static-site recipe.** `.ic-assets.json5` is the **legacy asset canister's** config file. The certified-assets canister does not read it — and because the plugin skips every dotfile and dot-directory (only `.well-known/` is exempt, Pitfall 12), a `.ic-assets.json5` in your `dir` is not even uploaded. It is silently absent, so SPA fallback, headers, and security policy do nothing. Configure this canister with `_redirects` and `_headers` instead (below).
2. **Wrong SPA fallback rule.** For client-side routing, the fallback is a **rewrite** in `_redirects`: `/* /index.html 200`. The `200` status is what makes it a rewrite (serve the shell's contents at the requested URL, no visible redirect) so deep links work on fresh load and reload. Do **not** use a `301`/`302` redirect, and do **not** reach for `enable_aliasing` — that is a legacy asset-canister setting and has no effect here.
3. **Relative asset paths in a SPA.** Link assets with **absolute** paths (`/assets/app.js`), never relative (`assets/app.js`). Under a `/*` rewrite, a relative URL resolves against the *client route*: at `/dashboard/settings` the browser requests `/dashboard/settings/assets/app.js`, which `/*` answers with the HTML shell — producing a confusing MIME-type error instead of loading your script.
4. **Expecting `AssetManager` / `@icp-sdk/canisters` to work.** The certified-assets canister's upload API is `upload_chunks` + `execute_operations`, **not** the SDK asset canister's `store`/`create_batch`/`commit_batch`. `AssetManager` from `@icp-sdk/canisters/assets` targets the *legacy* canister and does **not** work against static-site. Uploads happen through the recipe's sync plugin on `icp deploy`; there is no drop-in JS `AssetManager` equivalent for this canister. (If you need programmatic uploads, you are almost certainly on the legacy canister — see the legacy reference.)
5. **`_headers` / `_redirects` in the wrong place.** These two files must sit at the **root of your `dir`** (e.g. `dist/_redirects`). They are read as configuration and never served as assets. Put them in your `public/` (Vite) or `static/` folder so the build copies them into `dir` automatically — a file left at the project root but not copied into `dir` is simply absent at deploy time.
6. **Setting a reserved header in `_headers`.** The sync plugin **rejects** these at deploy time with an explanatory error: `Content-Length`, `Content-Encoding`, `ETag`, `Transfer-Encoding`, `Accept-Ranges`, `Content-Range`, `IC-Certificate`, `IC-CertificateExpression`, `Location`. To set an asset's media type use the bare `Content-Type:` form (it routes to asset metadata, not a response header). To redirect, use `_redirects` — a `Location` header in `_headers` would not redirect (status stays `200`).
7. **Assuming default security headers.** Unlike the legacy canister's `security_policy: "standard"`, the certified-assets canister adds **no default headers** — no `Cache-Control`, no CSP, no `X-Frame-Options`. If you want them, declare them in `_headers` (baseline below). The only headers it manages itself are the certification/serving ones and its `ic_env` cookie.
8. **A `404`/`410` rule pointing at a large file.** An error-page target must be a **small, single-chunk file** (under ~1.9 MB). Large files are served as certified `206` range responses that can't carry a 4xx status, so the plugin rejects such a rule at deploy time, naming it. (A `200` rewrite to a large file is fine.)
9. **Expecting dynamic redirect captures.** There is no `:splat` or `:placeholder` — you can't forward a captured segment (`/old/:rest → /new/:rest`). Every certifiable response must be enumerable ahead of time, so `_redirects`/`_headers` support only exact paths, a trailing `/*` subtree wildcard, and fixed destinations.
10. **Switching an existing project from the legacy asset canister to static-site.** Repointing `recipe:` at `@dfinity/static-site` and running a plain `icp deploy` **fails before anything is installed**: these are two unrelated canisters with unrelated Candid interfaces, so icp-cli's pre-install check aborts with `Candid interface compatibility check failed: '<canister>' … You are making a BREAKING change`. Run **`icp deploy --mode reinstall`** instead. That replaces the wasm with the certified-assets canister, **discards the old stable memory** — every legacy asset, permission, and `.ic-assets.json5`-derived setting is gone — and leaves the canister with empty state, after which the sync plugin uploads your whole `dir` from scratch. Deploying static-site as a brand-new canister avoids the question entirely. Do **not** silence the check with `--yes`: that pushes the in-place upgrade through onto stable memory certified-assets cannot read, leaving a live canister that serves nothing. See the migration reference.
11. **Assuming a recipe version bump re-installs itself.** Moving between certified-assets' **own** releases is gentler than the legacy switch above — the Candid interface is stable across a release series, so nothing blocks the deploy — but a breaking bump still needs a reinstall **you run yourself**. The canister and plugin are version-locked, so after bumping the recipe `icp deploy` upgrades in place and the sync plugin then refuses, reporting `assets canister version mismatch: canister is X, this plugin is Y` plus the fix: `icp canister install --mode upgrade` for a **patch** bump (state preserved) or `icp canister install --mode reinstall` for a **breaking** (pre-1.0 minor, post-1.0 major) bump, which wipes state so the next sync re-uploads every asset and redirect rule. A failed sync right after a version bump is this, not a bug.
12. **`.well-known/` is uploaded automatically — no config needed.** The plugin skips dotfiles and dot-directories *except* `.well-known/`, which it traverses normally. So `dir/.well-known/ic-domains` is served at `/.well-known/ic-domains` with no extra setting. (This is the opposite of the legacy canister, which needed an explicit `.ic-assets.json5` un-ignore rule.)
13. **Access protection ordering.** The recipe's `icp deploy` installs the canister **and** syncs assets together, so a plain deploy-then-`enable_protection` briefly serves your content publicly. For a brand-new *private* app, enable protection **before your real assets are synced** — deploy a `dir` containing only `login.html`, `enable_protection`, then deploy the full site — so assets are never world-readable. The login page must be **fully self-contained** (inline CSS/JS, `data:` URIs) — it is the only gate-exempt path, and any external subresource it references would itself be gated. See [Access protection](#access-protection-private-apps).
## SPA Routing and Redirects: `_redirects`
Add a `_redirects` file to the root of your `dir`. Syntax follows [Netlify's `_redirects`](https://docs.netlify.com/manage/routing/redirects/overview/) — one rule per line, three whitespace-separated fields (`from`, `to`, `status`); blank lines and `#` comments are ignored.
```
# Single-page-app fallback: serve the shell for any unmatched path (a rewrite, status 200)
/* /index.html 200
# Permanent redirect to a new internal path
/old-blog /blog 301
# Redirect to an external site
/discord https://discord.gg/example 302
# Rewrite: serve /content/article.html at a pretty URL, no visible redirect
/article /content/article.html 200
# Subtree redirect: everything under /docs/v1/ moves to /docs/v2/
/docs/v1/* /docs/v2/ 301
# Custom error pages (target must be a small single-chunk file)
/secret /403.html 404
/retired-feature /sunset.html 410
```
Status codes: `301`/`302`/`307`/`308` redirect; `200` is a **rewrite**; `404`/`410` serve an error page. `from` is always absolute and its only wildcard is a trailing `/*`. Precedence, first match wins: **real files** > automatic **clean-URL** rules > your `_redirects` (in file order) > the 404 fallback. Because a real file always wins, a `/*` SPA rule only catches paths nothing else claimed.
**SPA caveat — missing assets return HTML.** With `/* /index.html 200`, a typo'd `/assets/app-old.js` also matches `/*` and serves the shell as `text/html`. To give real 404s under a build-output folder, scope a narrower rule *above* the catch-all (rules match in file order):
```
/assets/* /404.html 404
/* /index.html 200
```
This needs a `404.html` in your `dir`; declaring `/*` means the built-in default 404 is not added.
## Custom Headers: `_headers`
Add a `_headers` file to the root of your `dir`. Syntax follows [Netlify's `_headers`](https://docs.netlify.com/manage/routing/headers/): a path pattern on its own line, followed by indented `Name: value` lines; a blank line or `#` ends a block. Patterns are absolute paths with an optional single `*` wildcard (which matches `/` too; there is no `**` or `?`).
A useful baseline — act like a senior security engineer and tighten the CSP for your app:
```
/*
X-Frame-Options: DENY
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Content-Security-Policy: default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'
# Fingerprinted build assets never change — cache them hard
/assets/*
Cache-Control: public, max-age=31536000, immutable
# HTML should revalidate so deploys are picked up
/*.html
Cache-Control: public, max-age=0, must-revalidate
```
Key rules:
- **Patterns match the file (asset key), not the visitor's URL.** Write `/index.html`, not `/`. For a SPA, a `Cache-Control` on `/index.html` (or `/*.html`) is what every `/*`-rewritten client route gets; a block written against a route like `/dashboard/*` matches no file and does nothing.
- **All matching blocks contribute** — a file matching several blocks gets every block's headers (same-name values are combined comma-separated; `Set-Cookie` stays separate).
- **`Content-Type` is special** — the bare `Content-Type: <type>` form overrides the stored media type of the matching file (use it for extension-less files like `/llms.txt`); it is single-valued, first-match-wins, and is *not* emitted as an ordinary header.
- Reserved headers are rejected at deploy time — see Pitfall 6.
## Clean URLs and the 404 Page
You don't link to `.html` files — the canister maps each HTML file to a clean, extension-less canonical URL and `307`-redirects the other forms to it:
| Your file | Canonical URL | Also handled (→ `307`) |
|-----------|---------------|------------------------|
| `dist/index.html` | `/` | `/index` |
| `dist/about.html` | `/about` | `/about/`, `/about/index` |
| `dist/blog/index.html` | `/blog/` | `/blog`, `/blog/index` |
Requesting the underlying `/about.html` directly currently serves it `200` (a file always beats a routing rule). For a custom 404, put a `404.html` at the root of your `dir`; otherwise a certified default is served. A SPA `/*` rule takes over the whole path space, so it replaces the default 404 with your shell.
## Custom Domains
Ver en GitHub