Skip to content
Blog

Deploying Flutter Web to Firebase Hosting

Ship a Flutter web build to a live URL on Firebase Hosting: release build flags, SPA rewrites, caching and CORS headers, custom domains, preview channels and a GitHub Actions deploy workflow.

Published on • October 17, 2026

AI Assistant

flutter build web finishes in under a minute and leaves a build/web folder full of hashed JavaScript, canvaskit files and an index.html. Getting that folder to a URL where deep links work, cache headers are sane and every push ships the new build is a separate problem, and it is where most Flutter web deployments go wrong: a blank page on refresh, a stale bundle served for a day, or a Wasm build that silently falls back because the server lacks headers.

This post is the full path from build command to live URL on Firebase Hosting: the release build and its flags, the firebase.json config with SPA rewrites and caching rules, custom domain setup, preview channels, and a GitHub Actions workflow that deploys on every merge.

Build the release bundle

The Flutter docs describe the release flow as a single command:

$ flutter build web

It compiles the app plus assets into build/web. Useful flags:

FlagEffect
--wasmCompile to WebAssembly
--source-mapsEmit .js.map / .wasm.map files for crash symbolication
--profile / --debugNon-release builds for profiling and assertion-enabled testing
--base-hrefSet the base path when the app is not served from the domain root

Two warnings straight from the docs are worth internalizing:

Do not publish source maps. With --source-maps, the .map files sit in build/web like everything else, and standard deployment tools upload the whole folder. The Flutter docs explicitly recommend excluding them from public hosting - for example with "ignore": ["**/*.map"] in firebase.json - and uploading them to your error-monitoring service instead. Unminified symbol names and local file paths are not something you want on a public CDN.

Wasm needs response headers. WebAssembly builds use multiple threads and require the server to return Cross-Origin-Embedder-Policy: credentialless and Cross-Origin-Opener-Policy: same-origin. You can add those in the headers block below; without them the Wasm path does not work as intended.

Release builds are minified and tree-shaken, profile builds are tree-shaken but not minified, debug builds are neither - so a debug build on a public URL is both slow and larger than it needs to be.

Validate before deploying:

$ python -m http.server 8000 -d build/web

Then open localhost:8000 and click through routes, refresh on a nested route, and confirm the release build (not a debug banner) is what you get.

Two ways to deploy

Option A: the framework-aware CLI

Firebase’s web frameworks integration builds Flutter for you:

$ npm install -g firebase-tools
$ firebase experiments:enable webframeworks
$ firebase init hosting

Answer “yes” when asked to use a web framework, choose Flutter Web, pick your hosting source directory (your Flutter project), choose a region, and decide whether to set up automatic GitHub deploys. Then:

$ firebase deploy

That runs flutter build web --release for you, so there is no separate build step.

Option B: build yourself, deploy the folder

More common in CI, where you already have a build step and want control over flags:

$ flutter build web --release
$ firebase deploy --only hosting

Either way the directory you point Firebase at is build/web.

The firebase.json config

public is the only required Hosting attribute; everything else is behavior you almost certainly want:

{
  "hosting": {
    "public": "build/web",
    "ignore": ["firebase.json", "**/.*", "**/node_modules/**", "**/*.map"],
    "rewrites": [
      { "source": "**", "destination": "/index.html" }
    ],
    "headers": [
      {
        "source": "**/*.@(js|css|wasm)",
        "headers": [
          { "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }
        ]
      },
      {
        "source": "**/index.html",
        "headers": [
          { "key": "Cache-Control", "value": "no-cache" }
        ]
      },
      {
        "source": "**/*.@(eot|otf|ttf|ttc|woff|font.css)",
        "headers": [
          { "key": "Access-Control-Allow-Origin", "value": "*" }
        ]
      },
      {
        "source": "**",
        "headers": [
          { "key": "Cross-Origin-Opener-Policy", "value": "same-origin" },
          { "key": "Cross-Origin-Embedder-Policy", "value": "credentialless" }
        ]
      }
    ]
  }
}

Why the rewrite exists

Firebase documents rewrites as the mechanism for “apps that use HTML5 pushState for navigation”: when a request matches a pattern, the server responds with the destination file’s contents instead of a 404. The basic rule is:

"rewrites": [{ "source": "**", "destination": "/index.html" }]

Two behaviors from the Hosting config reference to keep in mind:

  • A rewrite only applies if no file exists at the requested path. Assets resolve normally; unknown paths fall through to index.html.
  • Within redirects and rewrites, the first rule whose pattern matches wins, so ordering is deliberate, and configured redirects take precedence over rewrites.

If you ship the default hash URL strategy you technically do not need rewrites for deep links, but they cost nothing and protect you the moment you switch to a path strategy. (See the existing post on URL strategies for that trade-off.)

Caching rules that do not break deploys

Flutter’s output is content-hashed: main.dart.js becomes main.<hash>.js. That makes long-lived immutable caching safe for JS/CSS/Wasm, and it is why index.html must be no-cache - it is the only file pointing at the current hashes, and if it sticks in a browser cache you serve yesterday’s bundle with today’s URL expectations. The canonical failure is “deploy succeeded, users still see the old app” or a blank page because index.html references a main.<hash>.js the new deploy deleted.

Custom header notes from the docs:

  • Header pattern matching happens before rewrite rules are applied, and rules apply in the order defined.
  • The example in Firebase’s own docs uses Cache-Control: max-age=7200 to override the default one-hour browser cache for images, plus Access-Control-Allow-Origin: * for fonts. Use max-age for images you can re-version; use immutable only for hashed filenames.

Custom domains

From Firebase’s custom domain documentation, the flow in the console is: add the domain, get a TXT ownership record of the form hosting-site=[site_id], verify it, then complete SSL provisioning (DNS or HTTP ACME challenge), and finally point A/AAAA records at Hosting.

Facts that save you an afternoon:

  • Remove conflicting records. If A, AAAA or CNAME records still point at another provider, Firebase cannot provision the certificate.
  • Provisioning takes time. Certificate issuance can take up to 24 hours after DNS points at Firebase; expect a temporary invalid certificate while it runs.
  • Limits: one Hosting site per custom domain, and up to 20 subdomains per apex domain due to certificate minting limits.
  • The TXT ownership record must stay in place so Firebase can renew certificates.

Preview channels

Preview channels give every branch its own URL without touching live:

$ firebase hosting:channel:deploy pr-142 --expires 7d
$ firebase hosting:channel:list
$ firebase hosting:channel:open pr-142
$ firebase hosting:channel:delete pr-142

Related commands are firebase hosting:channel:create CHANNEL_ID and the rest of the firebase hosting:channel group. Preview URLs talk to the real backend resources of your Firebase project, so a preview build can write to production services - guard accordingly.

CI: deploy on every merge

Firebase documents two ways into CI. The interactive one is:

$ firebase init hosting:github

That creates a service account with Hosting deploy permissions, encrypts its JSON key into a GitHub secret, and writes workflow YAML files referencing it. Commit the generated files on a branch and merge.

The generated workflows are variations of FirebaseExtended/action-hosting-deploy, which Firebase maintains as an open-source project (labelled experimental in its own README). A preview-per-PR workflow:

name: Deploy to Preview Channel

on:
  pull_request:

jobs:
  build_and_preview:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      # Install a pinned Flutter SDK here (SDK archive or your team's preferred action)
      - run: flutter --version
      - run: flutter pub get
      - run: flutter build web --release
      - uses: FirebaseExtended/action-hosting-deploy@v0
        with:
          repoToken: "${{ secrets.GITHUB_TOKEN }}"
          firebaseServiceAccount: "${{ secrets.FIREBASE_SERVICE_ACCOUNT }}"
          projectId: your-firebase-project-id
          expires: 30d

And the live deploy on merge:

name: Deploy to Live Channel

on:
  push:
    branches: [main]

jobs:
  deploy_live_website:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      # Install a pinned Flutter SDK here (SDK archive or your team's preferred action)
      - run: flutter --version
      - run: flutter pub get
      - run: flutter build web --release
      - uses: FirebaseExtended/action-hosting-deploy@v0
        with:
          firebaseServiceAccount: "${{ secrets.FIREBASE_SERVICE_ACCOUNT }}"
          projectId: your-firebase-project-id
          channelId: live

Input details from the action’s README: expires defaults to 7 days and resets on each deploy; omit channelId to get an auto-generated preview channel per branch or PR, set it to live for production; entryPoint is the directory containing firebase.json (default .); firebaseToolsVersion defaults to latest; repoToken is what enables the PR comment with the preview URL; force passes the CLI’s --force.

Keep the Flutter build and the deploy as separate steps - it makes a failed compile obvious, and it lets you run flutter test before the deploy step instead of after.

Gotchas checklist before you flip DNS

  • index.html is no-cache; hashed JS/CSS/Wasm are long-lived; *.map files are never uploaded.
  • Deep link test: open /some/nested/route directly in a fresh tab, not by navigating inside the app.
  • Wasm builds get COOP/COEP headers if you enabled --wasm.
  • Fonts that are loaded cross-origin get Access-Control-Allow-Origin.
  • The Firebase CLI in CI is pinned (or intentionally latest) so a CLI major bump does not surprise you mid-deploy.

Checklist

  • flutter build web runs locally, and the release output is checked in a real browser (deep links, refresh, assets).
  • firebase.json points public at build/web and ignores **/*.map.
  • A catch-all rewrite serves /index.html for unmatched paths, listed after more specific rules.
  • Cache-Control: no-cache on index.html, long-lived immutable caching on hashed assets, and header patterns ordered before rewrites matter.
  • Custom domain has conflicting A/AAAA/CNAME records removed, ownership TXT in place, and time budgeted for SSL provisioning (up to 24 hours).
  • Preview channels are used for review builds, with awareness that previews hit real backend resources.
  • CI builds with flutter build web --release and deploys via FirebaseExtended/action-hosting-deploy with a service-account secret.
  • A post-deploy smoke test hits a nested URL on a cold cache.

Sources: Flutter - Build and release a web app, Firebase Hosting, Firebase Hosting full config reference, Deploy to live and preview channels via GitHub pull requests, Connect a custom domain to Firebase Hosting, Manage live and preview channels, FirebaseExtended/action-hosting-deploy