devShakib

Per-PR Preview Deploys with Firebase Hosting Channels and GitHub Actions

Set up per PR preview deploys with Firebase Hosting preview channels and GitHub Actions: deterministic channel IDs, PR comment URLs, and reliable teardown.

Every design review that starts with "can you push it somewhere I can click on it?" is a small tax on your day. For years my answer was to build locally, screen-share, and describe what the reviewer couldn't touch. The fix turned out to be almost free: a unique, live URL for every pull request, posted as a comment, that disappears on its own when the PR closes. No Kubernetes, no per-environment Terraform, no standing infrastructure to babysit. Just Firebase Hosting preview channels and a couple of GitHub Actions workflows.

This is a deep dive on that setup — including the part everyone skips: automatic teardown. Spinning previews up is the easy 80%. The 20% that bites you is the channels you forget about. By the end you'll have two YAML files that give every PR an ephemeral preview environment, a bot comment with a clickable URL, and a channel list that never turns into a graveyard of stale deploys.

Why Firebase Hosting preview channels instead of a second Hosting site

Firebase Hosting gives you two primitives that matter here. Your live channel is production. A preview channel is a temporary, isolated deploy of the exact same site, served on its own auto-generated URL like https://my-app--pr-42-a1b2c3d4.web.app, with a built-in expiration date. It shares your site's rewrites, response headers, and single-page-app fallback config, so what a reviewer sees is genuinely what production will serve — same routing, same caching rules, same redirects.

The reasons I reach for preview channels over a hand-rolled "staging site" or a separate Firebase project:

The catch: a preview channel is not a first-class GitHub environment. Nothing tears it down for you unless you wire it up. That's the whole game.

How a preview channel differs from a full staging environment

Worth being precise about the tradeoff, because a preview channel is not a drop-in replacement for a full staging stack. A channel deploys your static frontend only — the HTML, JS, CSS, and assets Firebase Hosting serves. It does not spin up a fresh backend, a fresh database, or fresh auth. If your app is a pure static site or a client-rendered SPA that talks to an API over HTTPS, that's perfect. If a PR needs its own isolated backend and seeded data, you'll pair the channel with a shared staging backend (covered later) rather than a per-PR one. Know which of those two worlds you're in before you invest.

The deploy-on-PR workflow

The official FirebaseExtended/action-hosting-deploy action does the heavy lifting: it authenticates with a service account, deploys to a channel named after the PR, and posts (or updates) a comment with the URL. Here's the shape I use for a Flutter web app.

name: PR Previewon:  pull_request:    types: [opened, synchronize, reopened]permissions:  checks: write  contents: read  pull-requests: writejobs:  preview:    runs-on: ubuntu-latest    steps:      - uses: actions/checkout@v4      - uses: subosito/flutter-action@v2        with:          channel: stable          cache: true      - 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-project-id          channelId: pr-${{ github.event.number }}          expires: 7d

Three things worth calling out:

The one non-obvious constraint: forks

On PRs from forks, GITHUB_TOKEN is read-only and your FIREBASE_SERVICE_ACCOUNT secret isn't exposed to the workflow, so this job won't run for external contributors. That's a feature, not a bug — you don't want a fork's arbitrary code deploying under your credentials and burning your Hosting quota. For internal team PRs on branches within the repo, it just works.

If you genuinely need previews for fork PRs (open-source projects do), the safe pattern is a separate pull_request_target workflow that checks out the base branch's workflow definition but builds the PR head, with tight guards. That's a sharper knife and out of scope here — for a private product repo, the default fork behavior is exactly what you want.

Setting up the service account

The FIREBASE_SERVICE_ACCOUNT secret is the whole trust chain, so get it right once. Run firebase init hosting:github locally and it will provision a service account, grant it the Firebase Hosting Admin role, and drop the JSON key straight into your repo's Actions secrets. If you'd rather do it by hand: create a service account in the Google Cloud console, give it the Firebase Hosting Admin role (that's the minimum that can create and delete channels), download a JSON key, and paste the entire JSON into a repository secret named FIREBASE_SERVICE_ACCOUNT. Scope it to Hosting only — this key should never be able to touch Firestore or your users.

The part everyone forgets: teardown

Here's the failure mode. You ship the workflow above, previews light up, everyone's happy. Six months later you run firebase hosting:channel:list and find forty dead channels from long-merged PRs. Each one held its own copy of your build. The expires TTL eventually reaps them, but "eventually" means you're carrying weeks of stale deploys at any given moment, and the channel list becomes noise you can't reason about. When something's actually wrong, you can't find the live preview in the pile.

The fix is a second workflow keyed on the PR closing. The same action supports removal when you don't pass a channelId on a closed event — but I prefer being explicit with the Firebase CLI so the intent is obvious to whoever reads this in a year.

name: PR Preview Teardownon:  pull_request:    types: [closed]jobs:  cleanup:    runs-on: ubuntu-latest    steps:      - uses: actions/setup-node@v4        with:          node-version: 20      - run: npm install -g firebase-tools      - name: Delete preview channel        env:          GOOGLE_APPLICATION_CREDENTIALS: ${{ runner.temp }}/sa.json          SA_KEY: ${{ secrets.FIREBASE_SERVICE_ACCOUNT }}        run: |          echo "$SA_KEY" > "$GOOGLE_APPLICATION_CREDENTIALS"          firebase hosting:channel:delete "pr-${{ github.event.number }}" \            --project your-project-id \            --force

A few details that make this robust:

One edge worth guarding: if the deploy job never created a channel for this PR (say it was closed before any preview ran), the delete will error on a missing channel. A continue-on-error: true on the step, or a || true appended to the command, keeps a harmless "nothing to delete" from turning your teardown into a red X.

Belt and suspenders

Even with teardown wired up, I keep two backstops, because CI jobs fail and network calls time out:

Here's the shape of that scheduled sweep:

name: PR Preview Sweepon:  schedule:    - cron: "0 3 * * 1"   # 03:00 UTC every Monday  workflow_dispatch:jobs:  sweep:    runs-on: ubuntu-latest    steps:      - uses: actions/setup-node@v4        with:          node-version: 20      - run: npm install -g firebase-tools      - name: List and prune stale pr-* channels        env:          GOOGLE_APPLICATION_CREDENTIALS: ${{ runner.temp }}/sa.json          SA_KEY: ${{ secrets.FIREBASE_SERVICE_ACCOUNT }}        run: |          echo "$SA_KEY" > "$GOOGLE_APPLICATION_CREDENTIALS"          firebase hosting:channel:list \            --project your-project-id --json > channels.json          # inspect channels.json, then delete stale pr-* entries with:          # firebase hosting:channel:delete "<id>" --project your-project-id --force

The workflow_dispatch trigger lets you run it on demand from the Actions tab when you just want to tidy up without waiting for Monday.

Making the preview URL genuinely useful

A preview is only worth the CI minutes if reviewers actually open it and can do something. Two habits paid off for me.

Point the app at a real backend, carefully

For a preview to be clickable-and-testable it needs data. I let previews hit a shared staging Firebase project — separate Firestore, separate auth, separate config — never production. Wire the target via --dart-define at build time so the channel URL and the data source always stay in lockstep:

flutter build web --release \  --dart-define=API_ENV=staging \  --dart-define=FIREBASE_PROJECT=your-staging-project

Read those with String.fromEnvironment in the app and select the right Firebase options at startup. The rule I hold to: a preview channel may read and write staging data freely, but it must be impossible for a preview build to touch production. If the only way to get production config is a define you never pass in the preview job, that invariant holds by construction.

Keep the build fast

The whole value proposition collapses if the reviewer waits ten minutes for a link that goes stale by the time they click it. Two levers:

Wait for the deploy before running smoke tests

If you run end-to-end tests against the preview URL, the deploy action outputs the live channel URL — capture it as a step output and feed it into a Playwright or integration run in a later step. That turns every PR into a full "built, deployed, and smoke-tested at a real URL" gate, not just a build check. It's the difference between "it compiles" and "it works when a human loads it."

Key takeaways

Ephemeral preview environments have a reputation for being a heavyweight platform-engineering project. For a static site or SPA frontend, they're two YAML files and a service-account secret. Set it up once, keep the TTL as your backstop, and "can you push it somewhere I can click?" stops being a question anyone on your team ever has to ask again.