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-canisterrecipe (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 — seereferences/legacy-asset-canister.md. To move an existing project over, seereferences/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 plaindir/build/presyncdeploy needs nothing else.ic-wasm—npm install -g @icp-sdk/ic-wasm. A separate binary, not bundled withicp-cli. This recipe shells out to it only when you set themetadatafield (the generated build guards oncommand -v ic-wasmand fails with “ic-wasm not found”); other official recipes need it unconditionally, so installing both up front is the safe default — see theicp-cliskill.- 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
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 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). |
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
-
Using
.ic-assets.json5with the static-site recipe..ic-assets.json5is 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.json5in yourdiris not even uploaded. It is silently absent, so SPA fallback, headers, and security policy do nothing. Configure this canister with_redirectsand_headersinstead (below). -
Wrong SPA fallback rule. For client-side routing, the fallback is a rewrite in
_redirects:/* /index.html 200. The200status 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 a301/302redirect, and do not reach forenable_aliasing— that is a legacy asset-canister setting and has no effect here. -
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/settingsthe browser requests/dashboard/settings/assets/app.js, which/*answers with the HTML shell — producing a confusing MIME-type error instead of loading your script. -
Expecting
AssetManager/@icp-sdk/canistersto work. The certified-assets canister’s upload API isupload_chunks+execute_operations, not the SDK asset canister’sstore/create_batch/commit_batch.AssetManagerfrom@icp-sdk/canisters/assetstargets the legacy canister and does not work against static-site. Uploads happen through the recipe’s sync plugin onicp deploy; there is no drop-in JSAssetManagerequivalent for this canister. (If you need programmatic uploads, you are almost certainly on the legacy canister — see the legacy reference.) -
_headers/_redirectsin the wrong place. These two files must sit at the root of yourdir(e.g.dist/_redirects). They are read as configuration and never served as assets. Put them in yourpublic/(Vite) orstatic/folder so the build copies them intodirautomatically — a file left at the project root but not copied intodiris simply absent at deploy time. -
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 bareContent-Type:form (it routes to asset metadata, not a response header). To redirect, use_redirects— aLocationheader in_headerswould not redirect (status stays200). -
Assuming default security headers. Unlike the legacy canister’s
security_policy: "standard", the certified-assets canister adds no default headers — noCache-Control, no CSP, noX-Frame-Options. If you want them, declare them in_headers(baseline below). The only headers it manages itself are the certification/serving ones and itsic_envcookie. -
A
404/410rule 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 certified206range responses that can’t carry a 4xx status, so the plugin rejects such a rule at deploy time, naming it. (A200rewrite to a large file is fine.) -
Expecting dynamic redirect captures. There is no
:splator:placeholder— you can’t forward a captured segment (/old/:rest → /new/:rest). Every certifiable response must be enumerable ahead of time, so_redirects/_headerssupport only exact paths, a trailing/*subtree wildcard, and fixed destinations. -
Switching an existing project from the legacy asset canister to static-site. Repointing
recipe:at@dfinity/static-siteand running a plainicp deployfails before anything is installed: these are two unrelated canisters with unrelated Candid interfaces, so icp-cli’s pre-install check aborts withCandid interface compatibility check failed: '<canister>' … You are making a BREAKING change. Runicp deploy --mode reinstallinstead. 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 wholedirfrom 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. -
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 deployupgrades in place and the sync plugin then refuses, reportingassets canister version mismatch: canister is X, this plugin is Yplus the fix:icp canister install --mode upgradefor a patch bump (state preserved) oricp canister install --mode reinstallfor 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. -
.well-known/is uploaded automatically — no config needed. The plugin skips dotfiles and dot-directories except.well-known/, which it traverses normally. Sodir/.well-known/ic-domainsis served at/.well-known/ic-domainswith no extra setting. (This is the opposite of the legacy canister, which needed an explicit.ic-assets.json5un-ignore rule.) -
Access protection ordering. The recipe’s
icp deployinstalls the canister and syncs assets together, so a plain deploy-then-enable_protectionbriefly serves your content publicly. For a brand-new private app, enable protection before your real assets are synced — deploy adircontaining onlylogin.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.
SPA Routing and Redirects: _redirects
Add a _redirects file to the root of your dir. Syntax follows Netlify’s _redirects — 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: 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, aCache-Controlon/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-Cookiestays separate). Content-Typeis special — the bareContent-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
To serve from your own domain, add a .well-known/ic-domains file to your dir, one domain per line:
example.com
www.example.com
.well-known/ is uploaded automatically (Pitfall 12), so the file is served where the IC boundary nodes look. Registering the domain itself (DNS records, ACME challenge, TLS provisioning) is a separate IC platform step — see the custom-domains skill.
Access Protection (private apps)
Put a login gate in front of an in-progress or preview app. Unauthenticated visitors get a certified 307 → <login_page> (HTML) or 401 (other assets). Configure it through controller-only canister methods:
# 1. Add a self-contained /login.html to your dir and deploy.
icp deploy
# 2. Turn the gate on, naming your login page.
icp canister call frontend enable_protection '("/login.html")'
# 3. Mint a credential (here: a chosen passphrase valid ~1 year). Pass value = null for a random token.
# issue_token takes a record (IssueTokenArgs), not positional args.
icp canister call frontend issue_token '(record { label = "owner"; ttl_secs = 31536000 : nat32; value = opt "my-passphrase" })'
Ordering & the public window. The
static-siterecipe’sicp deployinstalls the canister and syncs your assets in one step, so the sequence above serves your content publicly for the brief window between that first deploy andenable_protection— fine when gating an existing or preview app. To avoid any public exposure for a brand-new private app, enable protection before your real assets are synced: run the firsticp deploywith adircontaining onlylogin.html, thenenable_protection '("/login.html")', thenicp deployagain with the full site. The login page is gate-exempt, so nothing private is ever served unauthenticated (Pitfall 13). (Enabling on a truly empty canister also works — the canister reportsEnabledLoginPageMissingand self-heals toEnabledoncelogin.htmlis synced.)
| Method | Effect |
|---|---|
issue_token '(record { label = "<label>"; ttl_secs = <secs> : nat32; value = opt "<value>" })' |
Mints a token, returns its value. value = null → high-entropy random token. |
revoke_token '("<label>")' |
Removes every token with that label, live. |
list_tokens '()' |
Live tokens { label; expires_at } (controller-only). |
check_protection_status '()' |
Disabled, Enabled, or EnabledLoginPageMissing. |
disable_protection '()' |
Gate off, drops all tokens. |
Always pass the argument explicitly — '()' for the methods that take none. Called with no argument, icp canister call opens an interactive prompt instead of sending an empty one.
This is access gating, not confidentiality: node operators can read asset bytes and the token store, there is no rate-limiting, and it relies on the honest-replica/honest-gateway assumption. Use high-entropy random tokens for share links; enable before the first sync for a new private app (Pitfall 13). Full details in the certified-assets access-protection docs.
Authorizing Uploaders
Uploads (sync) are performed by canister controllers and by a separate set of authorized syncer principals. To let a CI/deploy principal sync without giving it full canister control, authorize it — do not --add-controller, which grants upgrade/settings/delete power far beyond uploading.
# Allow a principal to sync assets (upload), without making it a controller
icp canister call frontend authorize '(principal "<principal-id>")'
# List authorized syncers
icp canister call frontend list_authorized '()'
# Revoke
icp canister call frontend deauthorize '(principal "<principal-id>")'
Security Warning:
icp canister settings update frontend --add-controller <principal-id>grants full canister control (upgrade wasm, change settings, delete, drain cycles) — not just upload access. Preferauthorizefor deploy pipelines.
Building Against Canister IDs (presync vs build)
build runs before the canister exists, so it can’t know any canister IDs. When a client-side app must bake in the ID of a canister it calls, build it in presync — that runs at sync time, once IDs exist, and exports them:
canisters:
- name: frontend
recipe:
type: "@dfinity/static-site@v0.3.3"
configuration:
dir: dist
presync:
- npm ci
# $ICP_CLI_CID_BACKEND is the `backend` canister's principal.
- VITE_CANISTER_ID_BACKEND=$ICP_CLI_CID_BACKEND npm run build
Variables available to presync: ICP_CLI_CID (this canister), ICP_CLI_CID_<NAME> (each project canister — name upper-cased, non-alphanumerics → _, e.g. backend → ICP_CLI_CID_BACKEND), ICP_CLI_NETWORK, ICP_CLI_ENVIRONMENT.
Alternatively, read canister IDs at runtime in the browser from the ic_env cookie the canister sets on every HTML response (works both locally and on mainnet with no environment branching) — see the internet-identity and icp-cli skills for the safeGetCanisterEnv() pattern. Prefer the cookie over fetchRootKey().
What You Get Automatically
No configuration needed — on by default:
- Response certification — every response is certified and gateway-verified.
- Clean URLs —
307canonicalization (above). - Compression — compressible assets are stored gzip + Brotli alongside the original and negotiated per request via
Accept-Encoding. Compressible means: anytext/*; any+jsonor+xmlsuffix (soimage/svg+xml,application/xhtml+xml);application/javascript,application/json,application/xml,application/wasm; andfont/*exceptwoff/woff2(already compressed). An encoding is kept only if it actually came out smaller than the original. - ETag /
304 Not Modified— content-hash ETag; unchanged files aren’t re-downloaded. - A default certified
404— replaceable with your own/404.html. - The
ic_envcookie — on HTML responses, carrying canister IDs and the root key for the frontend.
Deploy & Verify
# Start the local network
icp network start -d
# Build + deploy everything (or just the frontend)
icp deploy
icp deploy frontend
# Mainnet (needs cycles)
icp deploy -e ic frontend
Re-running icp deploy re-syncs: the plugin diffs your directory against the canister and uploads only what changed.
# Canister is running
icp canister status frontend # Status: Running, non-zero memory
# Get the canister ID (there is no `icp canister id`; use status --id-only)
icp canister status frontend --id-only
# Fetch the index page (certified)
icp canister call frontend http_request '(record {
url = "/"; method = "GET"; body = vec {}; headers = vec {};
certificate_version = opt 2;
})' # → status_code = 200
# SPA fallback returns the shell, not 404
icp canister call frontend http_request '(record {
url = "/dashboard/settings"; method = "GET"; body = vec {}; headers = vec {};
certificate_version = opt 2;
})' # → 200 (index.html), NOT 404
# Open in a browser (this is the URL `icp deploy` prints)
# Local: http://frontend.local.localhost:8000
# Mainnet: https://<frontend-canister-id>.icp.net
Legacy Asset Canister and Migration
- Maintaining an existing
@dfinity/asset-canisterproject (.ic-assets.json5,AssetManageruploads,grant_permissionroles,allow_raw_access): seereferences/legacy-asset-canister.md. - Moving an existing asset-canister project to certified-assets / static-site: see
references/migrating-from-asset-canister.mdfor the config mapping and the reinstall-mode caveat.
Additional References
- Load
icp-clifor the recipe system,icp.yamlstructure, canister-ID injection, and theic_envcookie /safeGetCanisterEnv()pattern. - Load
custom-domainsfor DNS records, ACME challenge, and TLS provisioning of a custom domain. - Load
internet-identityfor reading the root key and canister IDs fromic_envin a frontend. - Full upstream user docs: certified-assets docs.