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:
flutter build web --release --no-web-resources-cdnThe output lands in build/web.
Each flag has a reason:
--releasemakes the app read.env; debug and profile builds read.env.local.--no-web-resources-cdnserves CanvasKit, the engine's WebAssembly renderer, from your own origin. Without it, every visit fetches CanvasKit fromwww.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:
{{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.jsonand/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 astext/plainorapplication/octet-stream. Set the type for that path explicitly. - With a
200and no redirect. Apple and Google do not follow one, so a redirect towww, to another path or to a sign-in page fails verification. Serve them overhttpson the exact domain the apps claim. - Ahead of the SPA fallback. A missing file answered with
index.htmlis a200of HTML, which fails as an invalid file instead of as the404that would point at the cause. Match only these two paths: every other/.well-known/path stays an app route, or a404, 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:
| Header | Value | What it does |
|---|---|---|
X-Content-Type-Options | nosniff | Browsers trust the declared content type |
Referrer-Policy | strict-origin-when-cross-origin | Other sites see your origin, never the full URL |
Content-Security-Policy | frame-ancestors 'none' | No other site can frame the app (clickjacking) |
Permissions-Policy | camera=(), 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:
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.comis your backend:httpsfor the API and Storage images,wssfor 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 withENABLE_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:
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:
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-typeIn 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
.envfrom its secret store or variables beforeflutter build web. A clean checkout has neither env file, andpubspec.yamllists both, so it must also create an empty.env.local. Without it the build stops withNo file or variants found for asset: .env.local. - Build from a clean checkout, so no developer's
.env.localships. - Upload the whole of
build/web, dot files included, replacing the previous release. - Run the host checks and fail the release if one fails.
Upgrades
Moving a self-hosted Floot backend to a newer Supabase release: pinned tags, every upgrade treated as a migration-compatibility event, and update.sh.
Mobile
Get the iOS and Android apps ready for the stores: the release env file, the URL scheme, verified links and OAuth redirects, Android's release signing and the iOS team and bundle id, then Flutter's release guides.