devShakib

A Free, Practical CI/CD Pipeline for Flutter Web on Firebase Hosting

Build a free Flutter web CI/CD pipeline with GitHub Actions and Firebase Hosting: per PR preview URLs, service account auth, aggressive caching, $0/month deploys.

For about a year, my "deploy process" for my portfolio site was a three-step ritual: flutter build web, cross my fingers, firebase deploy. It mostly worked, which is the dangerous part. Then one evening I shipped a build from a hotel in Abu Dhabi over Wi-Fi that dropped mid-upload, went to dinner, and left a half-deployed, blank-white-screen site live for about four hours. Nobody died. But a recruiter I'd been talking to opened the link during those four hours, and I got to explain why my personal site — the one thing on the internet I fully control — was broken.

That was the push. I moved the whole thing into GitHub Actions the next weekend. The setup below is what I've run since: build once, cache hard, get a live preview URL on every pull request, and ship to production automatically on merge. The headline is that it costs $0/month — GitHub's free Actions minutes and Firebase's free Hosting quota both cover it with room to spare — but the real win isn't the money. It's that I stopped being the single point of failure in my own deploy.

This is a complete, copy-pasteable CI/CD pipeline for Flutter web on Firebase Hosting: a GitHub Actions workflow, service-account authentication, per-PR preview channels, and dependency caching that keeps runs fast and free. Steal it, adapt it, ship.

Why automate deployment for a one-person site

I'll get the pushback out of the way, because I made this argument to myself for months: "It's a portfolio. It's one command. Why build a pipeline?"

Because the command isn't the problem. The human running the command under bad conditions is the problem. Manual deploys fail in a very specific pattern — they fail exactly when you're distracted, tired, on bad Wi-Fi, or rushing to fix something before a meeting. That's precisely when you'll skip flutter analyze, deploy the wrong branch, forget to rebuild after your last edit, or upload a half-finished bundle. CI doesn't get tired. It runs the same steps in the same order every single time, on a clean machine, or it refuses to deploy at all.

The second reason is subtler and, honestly, it changed how I work: a preview URL per pull request turns "trust me, it works" into "click this and see." Once every change has a real, shareable, production-identical link before it merges, you review differently. You catch the layout that breaks at 375px. You notice the font that didn't load because of a CSP header. You send the link to a friend and they spot the typo you'd read past ten times. That feedback loop is worth more than the automation itself.

There's a third reason I underestimated: continuous deployment shrinks your batch size. When shipping is a scary manual ceremony, you batch changes up and deploy rarely, which makes every deploy riskier because more moved at once. When deployment is a merge, you ship tiny changes constantly, and a broken deploy is trivially bisectable to one small PR. Small, frequent, reversible — that's the whole DevOps thesis, and it applies to a portfolio just as much as to a product.

The shape of the pipeline: two triggers, two behaviors

That's the entire mental model:

That split is the whole point. Firebase Hosting preview channels are a genuinely underrated feature: they're full, isolated deployments on a URL like your-site--pr-42-abc123.web.app, serving the exact bundle that would go live, with zero effect on your real domain. You get a staging environment per pull request without running or paying for staging infrastructure. No separate project, no separate config, no cost.

The mechanics matter here. A preview channel is a snapshot of your entire Hosting output served from an auto-generated subdomain. It respects the same firebase.json rewrites, headers, and clean-URL settings as production, which is why it catches config bugs a localhost build never will. If your SPA routing depends on a ** rewrite to /index.html, the preview channel exercises that exact rewrite. That's the difference between "works on my machine" and "works the way it'll actually be served."

Getting the Firebase credentials right, once

The old advice — still all over the internet — is to bake a FIREBASE_TOKEN into your secrets via firebase login:ci. Don't. That token is long-lived, tied to your entire Google account, and grants access to everything you can touch across all your projects. If it leaks from a CI log, you're not rotating one project's key, you're rotating your whole identity. It's the deploy-automation equivalent of using your root password as an API key.

Use a service account scoped to the single project instead. This is the principle of least privilege applied to CI: a machine identity that can deploy Hosting to one project and do nothing else. The Firebase CLI wires the whole thing up for you:

firebase init hosting:github

It walks you through connecting the repo, provisions a service account with the minimum Hosting permissions, drops a starter workflow into .github/workflows/, and stores the JSON key as a repo secret (named something like FIREBASE_SERVICE_ACCOUNT_YOUR_PROJECT_ID). The private key never lands in your repo — it lives only in encrypted GitHub Actions secrets, which are write-only from the UI and masked in logs.

If you'd rather do it by hand, go to the Google Cloud console, create a service account, give it the Firebase Hosting Admin role (that's the least privilege that can actually deploy), download the JSON key, and paste the full JSON into a GitHub Actions secret. Three rules I hold myself to regardless of method:

The GitHub Actions workflow

Here's the core of my .github/workflows/deploy.yml. One job does the build; the deploy step branches on the trigger.

name: Deploy Webon:  push:    branches: [main]  pull_request:# Cancel an in-flight run if you push again to the same PR/branch.# No point building commit N when N+1 already exists.concurrency:  group: deploy-${{ github.ref }}  cancel-in-progress: truejobs:  build_and_deploy:    runs-on: ubuntu-latest    steps:      - uses: actions/checkout@v4      - uses: subosito/flutter-action@v2        with:          channel: stable          cache: true   # caches the Flutter SDK across runs      # Cache pub packages so we don't re-download the world every run.      - uses: actions/cache@v4        with:          path: ~/.pub-cache          key: pub-${{ runner.os }}-${{ hashFiles('pubspec.lock') }}          restore-keys: pub-${{ runner.os }}-      - run: flutter pub get      - run: flutter analyze      - run: flutter test      - run: flutter build web --release      # PRs get an ephemeral preview channel.      - name: Deploy preview        if: github.event_name == 'pull_request'        uses: FirebaseExtended/action-hosting-deploy@v0        with:          repoToken: ${{ secrets.GITHUB_TOKEN }}          firebaseServiceAccount: ${{ secrets.FIREBASE_SERVICE_ACCOUNT }}          projectId: your-project-id          expires: 7d      # main goes straight to production.      - name: Deploy live        if: github.ref == 'refs/heads/main' && github.event_name == 'push'        uses: FirebaseExtended/action-hosting-deploy@v0        with:          repoToken: ${{ secrets.GITHUB_TOKEN }}          firebaseServiceAccount: ${{ secrets.FIREBASE_SERVICE_ACCOUNT }}          projectId: your-project-id          channelId: live

A few things I'm deliberate about here, learned the boring way:

The GITHUB_TOKEN vs. the service account

People conflate these two secrets, so to be explicit: secrets.GITHUB_TOKEN is auto-injected by Actions and only grants permissions within this repo (commenting on the PR). secrets.FIREBASE_SERVICE_ACCOUNT is the one you created and it grants deploy access to Firebase. Different scopes, different owners, both needed. The Firebase one should never appear in any run: step — it's consumed only by the deploy action.

Pinning your Flutter version for reproducible builds

An early version of this pipeline passed CI and then served a broken site, because flutter test was green but I'd never actually built web with the pinned SDK. Pin your Flutter version explicitly once your app grows past a toy. channel: stable is fine for a portfolio, but for anything a team touches, set a concrete version so a stable-channel bump doesn't quietly change your build under you:

- uses: subosito/flutter-action@v2  with:    flutter-version: '3.24.3'    cache: true

Reproducible builds mean the version that's green in CI is the exact version that ships. "It builds on my machine" and "it builds in CI" should never be allowed to diverge — and the way you guarantee that is by removing "whatever stable happens to be today" from the equation. Bump the pinned version deliberately, in its own PR, so you can see the diff a new SDK causes in isolation rather than discovering it tangled into an unrelated change.

Caching Flutter web CI: where the time actually goes

On a cold Flutter web CI run, most of the wall-clock time isn't compiling your code — it's downloading the SDK and resolving pub packages. My uncached runs sat around six to seven minutes; cached, they're closer to two and a half. Two caches do almost all of that work:

Why key on pubspec.lock, not pubspec.yaml

Exactness. pubspec.yaml has version ranges; the lockfile has the resolved versions. You want the cache to invalidate precisely when the actual installed set changes, not when you tweak a caret constraint that resolves to the same thing. Key on the manifest and you'd needlessly rebuild the cache on cosmetic edits; key on the lockfile and the cache tracks reality. (This also means you should commit pubspec.lock for an app — which you should be doing anyway for reproducibility.)

Cache the inputs, rebuild the output

I deliberately do not cache the build/ directory, and this is a hill I'll defend. Flutter web builds aren't reliably incremental across fresh CI runners, and a stale build cache is a far nastier bug than a clean rebuild — it's the kind that ships old JavaScript alongside new HTML and takes you an hour of "but I fixed that" to diagnose. Cache the inputs, rebuild the output. Determinism beats a saved minute every time. The caches above are safe precisely because they're inputs keyed on their own fingerprints; a cached build artifact has no such honest key.

Keeping the pipeline genuinely free

This is the part people get nervous about, so let me be concrete about exactly where the free lines sit and why a site like this never crosses them.

One myth worth killing: the Blaze plan is not "the paid plan." I run Blaze so I have headroom to add a Cloud Function or bump limits later, and my hosting bill is still exactly $0. Blaze means "you can pay if you exceed the free tier," not "you pay." Static hosting almost never exceeds it. The free tier isn't a trial that expires — it's a permanent floor, and Blaze just removes the ceiling above it.

If you want a hard guarantee, set a billing budget alert at $1 in Google Cloud. You'll get an email long before anything real happens, and you can sleep. I've had that alert armed for years and it has never fired. For total peace of mind you can pair it with a low daily quota cap on any service you're nervous about — but for pure static Hosting, the budget alert alone is plenty.

Edge cases and gotchas worth knowing

A few things that bit me or that I've watched bite others:

What I'd add next, and what I'd skip

I keep this pipeline deliberately small, but two extensions have earned their place on larger projects:

And what I'd skip: matrix builds across Flutter channels, elaborate multi-environment promotion flows, anything with the word "orchestration" in it. This is a web app going to one place. The pipeline should be boring, legible, and fit on one screen. Every clever thing you add is a thing that breaks at 11pm when you just wanted to fix a typo.

Key takeaways

Wrapping up

The whole setup is one YAML file, one service-account secret, and two caches. Once it's in place, my workflow collapses to: open a PR, click the preview URL the bot posts, glance at it on my phone, merge when it looks right, and watch production update itself. No local builds, no manual firebase deploy, no shipping a blank white screen from a hotel lobby ever again.

If you're deploying a Flutter web app by hand today, don't try to build all of this at once. Wire up the PR-preview half first — even before you automate production. Seeing a real, live URL for every change you make is the single piece that actually changes how you work. The automated production deploy is just the reward you give yourself once you trust the preview.