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. The canister certifies every response (certification v2 only, never the no_certification escape hatch, no way to turn it off), and a verifying HTTP gateway checks that proof before handing the response to the browser: visitors get content the canister provably committed to, not something altered in transit. Whether the proof is checked depends on the gateway the visitor goes through: https://<canister-id>.icp.net verifies, https://<canister-id>.raw.icp.net does not (Pitfall 14).
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.4.0"
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. (v0.3.x→v0.4.0is breaking: reinstall, thenicp deploy.) A failed sync right after a version bump is expected — 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. -
Sharing or linking a
raw.icp.netURL.<canister-id>.raw.icp.netserves the same site as<canister-id>.icp.net, but the gateway forwards responses without checking the certificate, so the visitor gets no more assurance than from an ordinary web host. There is no canister setting to refuserawrequests (unlike the legacy canister’sallow_raw_access): the only clue is the unauthenticatedHostheader, and which hostnames verify is a gateway deployment detail, not protocol. Link tohttps://<canister-id>.icp.net(or a custom domain on a verifying gateway) and treatrawas a debugging tool only. With access protection on, arawURL is actively dangerous: the redirect and login page arrive unverified, so a non-verifying gateway could serve a fake login form and harvest tokens. The access cookie is host-only, so a session on the certified host does not carry over toraw.
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.Set-Cookieis allowed, but don’t use the nameic_env. The canister adds its ownic_envcookie to HTML responses as twoSet-Cookieheaders with the same name and value: oneSameSite=Lax, oneSameSite=None; Partitioned(so it survives in a cross-site iframe). Seeing twoic_envheaders is expected, not a duplicate-header bug.- 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. The gate runs canister-side on every hostname, but its redirect and login page are only verified through a verifying gateway, so never send people to a raw URL of a protected app (Pitfall 14). 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.4.0"
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; a verifying gateway (not
raw) checks the proof. - 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 (as twoSet-Cookieheaders,LaxandNone; Partitioned), carryingPUBLIC_*env vars (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
Verifying Deployed Contents (state hash)
Certification proves responses match what the canister committed to; the state hash proves what it committed to matches a build reproduced from public source. The hash icp deploy prints (canister reports state hash <hex>) comes from the canister itself — a deploy self-consistency check, not third-party verification. To verify a canister:
# 1. Reproduce the build from source at the deployed version → ./dist (incl. _headers/_redirects)
# 2. Ask the canister its release, then build the verifier from THAT tag.
# Not published as a binary or on crates.io. --locked is required: Cargo.lock pins the
# compressor builds whose output bytes the hash covers; without it the hash differs.
icp canister call <canister-id> version '()' -n ic --query
# (record { major = 0 : nat32; minor = 4 : nat32; patch = 0 : nat32 })
cargo install --git https://github.com/dfinity/certified-assets --tag v0.4.0 --locked state-hash-cli
# 3. Compute the hash locally (prints 64 hex chars)
state-hash ./dist
# 4. Read the canister's hash (an update call, so consensus-backed); the hash is the last 32 bytes
icp canister call <canister-id> state_hash '()' -n ic -o hex | tail -c 65
- Target the canister by principal with
-n <network>;-e <environment>resolves a canister name from a local project, which a third-party verifier doesn’t have. Pass'()'explicitly. - 32 zero bytes is not a hash: the canister has never finished a sync, or one is in progress (a sync drops the cached hash when it starts). Re-read after the deploy completes.
- A mismatch means content, headers, or redirects differ from the source — or the canister was prepared with non-standard compressors (platforms embedding the crates may do this; the tool only reproduces what
icp deploydoes). - Not covered by the hash: the
ic_envcookie (a controller can repoint a frontend’s backend canister ID via env vars with a byte-identical build) and access protection (a matching hash says the canister holds your build, not that a visitor can reach it).
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.