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.
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.
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 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: 7dThree things worth calling out:
channelId: pr-${{ github.event.number }} is deterministic. Re-running on synchronize (a new push to the PR) redeploys the same channel instead of creating a new one, so the URL is stable across the life of the PR. That stability is why the bot comment can update in place rather than spamming a new comment per push.expires: 7d is the safety net. Even if teardown never runs, the channel self-destructs in a week. Set it to whatever your review cadence tolerates — I keep it short enough that a forgotten channel can't linger, long enough that a slow reviewer won't hit a dead link mid-review.pull-requests: write is what lets the action post the comment. Skip it and the deploy succeeds but nobody gets a link — a very common "it's not working" that isn't actually broken. The deploy step and the comment step have separate permission needs, and only the comment fails silently.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.
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.
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 \ --forceA few details that make this robust:
closed, which fires for both merged and abandoned PRs. GitHub doesn't distinguish at the workflow-trigger level, and that's exactly what you want. Merged or not, the preview has served its purpose.--force skips the interactive confirmation the CLI would otherwise wait for forever in a non-interactive CI runner. Without it the job hangs until it times out.GOOGLE_APPLICATION_CREDENTIALS. The Firebase CLI reads that env var and uses the service-account JSON, so no firebase login and no interactive OAuth. Write the secret to a temp file, point the var at it, done.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.
Even with teardown wired up, I keep two backstops, because CI jobs fail and network calls time out:
expires TTL stays on every deploy. If the teardown job flakes, the channel still dies on schedule. Teardown is an optimization; expiration is the guarantee. This distinction is the single most important idea in the whole setup — never rely on the close event alone.pr-* older than N days catches the rare case where both the close event and the TTL somehow slipped. firebase hosting:channel:list --json piped into a filter is all it takes; run it weekly and forget about it.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 --forceThe workflow_dispatch trigger lets you run it on demand from the Actions tab when you just want to tidy up without waiting for Monday.
A preview is only worth the CI minutes if reviewers actually open it and can do something. Two habits paid off for me.
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.
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:
cache: true on subosito/flutter-action caches the Flutter SDK, and pub caches its packages between runs. First run is cold; every run after is dramatically faster.types: [opened, synchronize, reopened] filter means you don't rebuild on every label change, assignee tweak, or draft toggle. If you want to skip drafts entirely, gate the job with if: github.event.pull_request.draft == false.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."
pr-${{ github.event.number }}) so redeploys are idempotent and the bot comment updates in place instead of piling up.pull_request: closed, use --force for non-interactive CI, and tolerate a missing channel gracefully.expires TTL as the real guarantee, teardown as an optimization. A flaky cleanup job should never leave you carrying a graveyard of stale deploys — and a weekly scheduled sweep is a cheap third line of defense.--dart-define so a preview build can hit shared staging but can never reach production.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.