Flootdocs

Web

Build the Flutter web app for production and host it: the release build, the public env file, caching, the SPA fallback, the verified-link association files, a report-only CSP and the Auth redirect URLs.

A release web build is a folder of static files. Any static host or web server can serve it, as long as it meets the four hosting requirements below, which follow from how Flutter names its output and from the apps' verified links. This page builds the folder, states those requirements, then shows them in Caddy, the way Floot serves its own web app, app.floot.dev.

Read Going to production first. Its rule that the app's .env is public matters most here: on the web, anyone can download the file.

The examples use app.myapp.com for your web app and api.myapp.com for your backend.

The release build

Prepare .env with your production values and list it in pubspec.yaml, as Building for release shows. The web build reads the same file as the store builds.

Build from the project root:

Terminal
flutter build web --release --no-web-resources-cdn

The output lands in build/web.

Each flag has a reason:

  • --release makes the app read .env; debug and profile builds read .env.local.
  • --no-web-resources-cdn serves CanvasKit, the engine's WebAssembly renderer, from your own origin. Without it, every visit fetches CanvasKit from www.gstatic.com: Google sees each visitor, the app fails to start wherever that CDN is blocked, and a Content Security Policy has to allow it.
  • JavaScript only, no --wasm. The kit is built and tested as JavaScript. A Wasm build runs only in Chromium-based browsers and every other visitor gets JavaScript anyway, so you would have two runtimes to test.

The app is served from the root of its domain, Flutter's default base href /. To serve it under a path instead, add --base-href /path/.

Fallback fonts still come from Google

--no-web-resources-cdn does not cover the engine's fallback fonts: the Noto files it fetches when a screen shows a glyph no bundled font has, such as an emoji or a CJK character. Those still load from fonts.gstatic.com.

That is harmless for most apps. If no third party may see a visit, host the fonts yourself and point the engine at them in web/flutter_bootstrap.js, which Flutter uses in place of its generated bootstrap when the file exists:

web/flutter_bootstrap.js
{{flutter_js}}
{{flutter_build_config}}

const serviceWorkerVersion = {{flutter_service_worker_version}};
_flutter.loader.load({
  ...(serviceWorkerVersion == null ? {} : { serviceWorkerSettings: { serviceWorkerVersion } }),
  config: _flutter.buildConfig.useLocalCanvasKit
    ? { fontFallbackBaseUrl: "assets/fonts/fallback/" }
    : {},
});

Then copy every font file that main.dart.js names, by its path under https://fonts.gstatic.com/s/, into build/web/assets/fonts/fallback/ at the same path. The setting applies only to a build that serves CanvasKit locally, so flutter run keeps Google's fonts.

The env file is public

Every visitor can download your env files

A web build serves the bundled env files like any other asset: https://app.myapp.com/assets/.env, and .env.local beside it at /assets/.env.local, because pubspec.yaml lists both. Put nothing in either file that you would not publish.

Only .env is read in release, but .env.local ships too. On your laptop it holds your local values; build releases from a checkout whose .env.local holds nothing private, such as a CI checkout with an empty one (see In a pipeline).

The app needs /assets/.env to start: a release build that cannot load it throws before the first frame, and the page stays blank. Some hosts skip files whose names start with a dot when you upload, Firebase Hosting among them (its default ignore list holds **/.*). Check after every deploy that the file is served, with the checks in Check the host.

Hosting requirements

Four rules, whatever serves the files:

Revalidate every response

No output file carries a content hash in its name: main.dart.js is called main.dart.js in every release. A browser or CDN that keeps it for an hour runs last release's code against today's backend for that hour, and can pair a new index.html with an old main.dart.js.

So every response carries Cache-Control: no-cache, with an ETag or Last-Modified header. A repeat visit then revalidates each file and gets a 304 Not Modified: a round trip, not a download. Flutter's own documentation recommends max-age=0, must-revalidate, which behaves the same. A short max-age only shortens the time old code runs; it does not remove it.

The release build's service worker does not change this. Since Flutter 3.41, flutter_service_worker.js is a stub that unregisters itself, so a fresh visitor never runs a worker and nothing is cached outside HTTP.

An SPA fallback that leaves build files alone

The app uses path URLs (/invite/…, not /#/invite/…), so a link into the app names a route, not a file. The host must answer a path with no file behind it with /index.html, and the app's router takes it from there.

The exception is the build's own output. A missing main.dart.js answered with index.html fails as a JavaScript parse error, and a missing font as a corrupt font, instead of as the 404 that would point at the cause. So these paths are never rewritten, and a missing file under them is a 404:

  • /assets/*, /canvaskit/*, /icons/* and /main.dart.*, as Flutter's documentation advises;
  • the top-level build files: /version.json, /flutter.js, /flutter_bootstrap.js, /flutter_service_worker.js, /manifest.json and /favicon.png.

The association files, as JSON

The apps open your web app's links only once Apple and Google have fetched these two files from the domain and found your app in them (see Verified links):

  • /.well-known/apple-app-site-association, for iOS and macOS;
  • /.well-known/assetlinks.json, for Android.

They ship in web/.well-known/, and flutter build web copies them into build/web/.well-known/. Fill in their values first, as Deep-link domains shows. The host then has to serve each one:

  • As application/json. The Apple file has no extension, so a server that guesses the type from the name or the content sends it as text/plain or application/octet-stream. Set the type for that path explicitly.
  • With a 200 and no redirect. Apple and Google do not follow one, so a redirect to www, to another path or to a sign-in page fails verification. Serve them over https on the exact domain the apps claim.
  • Ahead of the SPA fallback. A missing file answered with index.html is a 200 of HTML, which fails as an invalid file instead of as the 404 that would point at the cause. Match only these two paths: every other /.well-known/ path stays an app route, or a 404, as you choose.

Apple's devices fetch the Apple file through Apple's CDN, not from your server, when the app is installed or updated, and the CDN keeps its copy for a while. After a fix, expect a delay before the CDN serves it, then reinstall the app so the device fetches it again.

A report-only CSP first

Send these headers from the first deploy; none of them can break the app:

HeaderValueWhat it does
X-Content-Type-OptionsnosniffBrowsers trust the declared content type
Referrer-Policystrict-origin-when-cross-originOther sites see your origin, never the full URL
Content-Security-Policyframe-ancestors 'none'No other site can frame the app (clickjacking)
Permissions-Policycamera=(), geolocation=(), microphone=(), payment=(), usb=()Turns off browser features the app never uses

Then add the full Content Security Policy as Content-Security-Policy-Report-Only. The browser reports, in its console, every request the policy would block, and blocks none of them. Once a release has run clean with it, rename the header to Content-Security-Policy to enforce it.

Floot's policy, with your hosts in place of Floot's:

Content-Security-Policy-Report-Only
default-src 'self';
script-src 'self' 'wasm-unsafe-eval' https://accounts.google.com/gsi/client;
style-src 'self' 'unsafe-inline' https://accounts.google.com/gsi/style;
img-src 'self' data: blob: https://api.myapp.com;
font-src 'self' data:;
connect-src 'self' https://api.myapp.com wss://api.myapp.com https://accounts.google.com/gsi/;
frame-src https://accounts.google.com/gsi/;
worker-src 'self' blob:;
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'none'

Sent as one header line. Its parts:

  • 'wasm-unsafe-eval' lets CanvasKit, which is WebAssembly, run.
  • api.myapp.com is your backend: https for the API and Storage images, wss for Realtime. On Supabase Cloud it is <ref>.supabase.co.
  • The accounts.google.com/gsi/ origins are Google Identity Services, which Google Sign-In's web plugin loads when the app starts with ENABLE_GOOGLE_SIGN_IN=true. Leave them out if Google Sign-In is off.
  • If you keep the fallback fonts on Google (see Fallback fonts still come from Google), expect reports naming fonts.gstatic.com, and allow it.

Social sign-in and Stripe Checkout are same-tab redirects, not popups or frames, so they need nothing in the policy.

HSTS belongs to whatever terminates TLS for the app: your host, or the CDN in front of it.

Where to host it

A static host

Any static host that lets you set response headers and a rewrite rule can meet the four requirements; most read them from a headers file and a redirects or rewrites file in the upload. Upload the whole build/web folder, dot files included, then run the checks below. A host whose single-page mode rewrites every missing path, assets included, fails the second requirement: prefer one that lets you exclude paths. Set the Apple file's Content-Type in the headers file, by its exact path, since nothing else names its type.

A CDN in front of your host follows the same headers. Cloudflare, for one, does not cache a no-cache response, so its edge never holds a stale main.dart.js. Set its Browser Cache TTL to Respect existing headers, so it passes your Cache-Control to browsers unchanged.

Caddy

Floot serves its web app with Caddy. Here is the whole site for a build copied to /srv/myapp, on a server where Caddy also obtains the certificate:

Caddyfile
app.myapp.com {
	root * /srv/myapp
	encode zstd gzip

	header {
		Cache-Control "no-cache"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "strict-origin-when-cross-origin"
		Content-Security-Policy "frame-ancestors 'none'"
		Permissions-Policy "camera=(), geolocation=(), microphone=(), payment=(), usb=()"
		Content-Security-Policy-Report-Only "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' https://accounts.google.com/gsi/client; style-src 'self' 'unsafe-inline' https://accounts.google.com/gsi/style; img-src 'self' data: blob: https://api.myapp.com; font-src 'self' data:; connect-src 'self' https://api.myapp.com wss://api.myapp.com https://accounts.google.com/gsi/; frame-src https://accounts.google.com/gsi/; worker-src 'self' blob:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'"
	}

	@association path /.well-known/apple-app-site-association /.well-known/assetlinks.json
	handle @association {
		header Content-Type "application/json"
		file_server
	}

	@build_output {
		path /assets/* /canvaskit/* /icons/* /main.dart.* /version.json
		path /flutter.js /flutter_bootstrap.js /flutter_service_worker.js /manifest.json /favicon.png
	}
	handle @build_output {
		file_server
	}

	handle {
		try_files {path} /index.html
		file_server
	}
}

Caddy's file_server sends an ETag and a Last-Modified header on its own, and serves dot files. Two path lines in one matcher are combined: a request matching either is build output.

Caddy tries handle blocks with a matcher before the one without, so the association files and the build output never reach the fallback. Without the @association handler, Caddy would sniff the extensionless Apple file as text/plain, and a missing file would fall back to index.html.

Floot applies the same rules from a Caddy container image, behind the server's main Caddy, which terminates TLS. On a server set up by the self-hosted guide, Your web app on this server serves these rules as a second site beside your backend, with no image.

Check the host

Run these against the live site after each deploy:

Terminal
curl -sI https://app.myapp.com/main.dart.js | grep -i -e cache-control -e etag
curl -s -o /dev/null -w '%{http_code}\n' https://app.myapp.com/assets/.env
curl -s -o /dev/null -w '%{http_code}\n' https://app.myapp.com/main.dart.missing
curl -s -o /dev/null -w '%{http_code}\n' https://app.myapp.com/some/app/route
curl -sI https://app.myapp.com/ | grep -i content-security-policy
curl -sI https://app.myapp.com/.well-known/apple-app-site-association | grep -i -e '^HTTP' -e content-type
curl -sI https://app.myapp.com/.well-known/assetlinks.json | grep -i -e '^HTTP' -e content-type

In order, you should see: no-cache and an ETag; 200, the app can load its env file; 404, a missing build file is not rewritten; 200, an app route falls back to index.html; both CSP headers; then 200 and application/json for each association file, with no 301 or 302 before it.

Auth redirect URLs

On the web, social sign-in leaves the page for the provider and comes back in the same tab, to the page it started from: the sign-in page, with the redirectTo that names where the visitor goes next, such as https://app.myapp.com/login?redirectTo=.... Supabase Auth sends a user only to a URL it accepts: one on the Site URL's host, or one on the allow-list. List your web app's paths, not only its origin, so the return works whatever the Site URL is set to.

So, on the backend:

  • Site URL is your web app's origin, https://app.myapp.com. Auth builds its email links on it.
  • Redirect URLs include your web app's paths, https://app.myapp.com/**, next to the mobile app's <your scheme>://oauth-callback.

On Supabase Cloud both are on Authentication → Configuration → URL Configuration (see URLs); self-hosted, they are GOTRUE_SITE_URL and GOTRUE_URI_ALLOW_LIST (see Auth defaults that differ). A preview on another origin needs its own Redirect URL entry, and signs in back to the Site URL, not to itself.

In a pipeline

The kit ships no CI workflow. A pipeline that builds and publishes the web app must:

  • Run after the backend deploy succeeded, never before it.
  • Write .env from its secret store or variables before flutter build web. A clean checkout has neither env file, and pubspec.yaml lists both, so it must also create an empty .env.local. Without it the build stops with No file or variants found for asset: .env.local.
  • Build from a clean checkout, so no developer's .env.local ships.
  • Upload the whole of build/web, dot files included, replacing the previous release.
  • Run the host checks and fail the release if one fails.

On this page