The keyless deploy post gets main → live Firebase Hosting with no stored secrets. The missing half is staging: previewing every change at a shareable URL before it goes live. Firebase Hosting preview channels do exactly that — ephemeral, auto-expiring copies of your site, isolated from the live channel — and we can drive them from a dev branch using the same Workload Identity Federation (WIF), at no extra cost and with no new infrastructure.
The end state:
| Branch | Pipeline | Result |
|---|---|---|
push dev | build → staging | a Firebase preview channel at https://SITE--staging-HASH.web.app (auto-expires) |
merge to main | build → deploy | the live site |
Branch-isolated identities
The important design choice: dev pipelines must not be able to impersonate the live-deploy service account. We enforce that at the IAM layer, not in scripts, by giving staging its own service account, trusted only for dev.
The trick is a mapped attribute on the OIDC provider that emits a distinct value per branch. Update your provider so it derives attribute.prod for main and attribute.staging for dev:
gcloud iam workload-identity-pools providers update-oidc gitlab \
--location=global --workload-identity-pool=gitlab-pool \
--attribute-mapping="\
google.subject=assertion.sub,\
attribute.project_id=assertion.project_id,\
attribute.prod=(assertion.ref=='main' && assertion.ref_type=='branch') ? 'live' : 'untrusted',\
attribute.staging=(assertion.ref=='dev' && assertion.ref_type=='branch') ? 'preview' : 'untrusted'"
Now create a separate staging service account with the Firebase Hosting roles, and bind it so that only dev-branch tokens (attribute.staging/preview) may impersonate it:
gcloud iam service-accounts create gitlab-staging --display-name="GitLab CI Firebase staging"
for ROLE in roles/firebasehosting.admin roles/firebase.viewer; do
gcloud projects add-iam-policy-binding PROJECT_ID \
--member="serviceAccount:gitlab-staging@PROJECT_ID.iam.gserviceaccount.com" --role="$ROLE"
done
gcloud iam service-accounts add-iam-policy-binding gitlab-staging@PROJECT_ID.iam.gserviceaccount.com \
--role="roles/iam.workloadIdentityUser" \
--member="principalSet://iam.googleapis.com/projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/gitlab-pool/attribute.staging/preview"
Bind your existing live-deploy SA the same way but to attribute.prod/live. The result: a dev pipeline can only assume gitlab-staging, a main pipeline only gitlab-deploy. Neither can cross over — guaranteed by IAM.
Don’t repeat the auth — share a template
Both the live deploy and the staging jobs use the same OIDC auth, so factor it into a hidden .firebase_oidc job that each extends:
.firebase_oidc:
image: node:22-slim
id_tokens:
GITLAB_OIDC_TOKEN:
aud: https://gitlab.com
before_script:
# Clear any legacy var — firebase-tools prefers FIREBASE_TOKEN over WIF.
- unset FIREBASE_TOKEN
- printf '%s' "$GITLAB_OIDC_TOKEN" > /tmp/oidc.jwt
- |
cat > /tmp/creds.json <<EOF
{
"type": "external_account",
"audience": "$WIF_AUDIENCE",
"subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
"token_url": "https://sts.googleapis.com/v1/token",
"service_account_impersonation_url": "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/$SERVICE_ACCOUNT_EMAIL:generateAccessToken",
"credential_source": { "file": "/tmp/oidc.jwt", "format": { "type": "text" } }
}
EOF
- export GOOGLE_APPLICATION_CREDENTIALS=/tmp/creds.json
- npm install -g firebase-tools
The preview-channel baseURL gotcha
This one cost me a confused half-hour. If you deploy your production build (baseURL https://yoursite.com/) to a preview channel, the page loads unstyled. Why? Hugo emits the stylesheet as an absolute URL with a Subresource Integrity hash:
<link rel="stylesheet" href="https://yoursite.com/css/style.HASH.css" integrity="sha256-…">
On the *.web.app channel that link is cross-origin, and SRI on a cross-origin <link> without a crossorigin attribute is blocked by the browser — so the CSS never applies. The fix is to build the staging artifact with the channel’s own baseURL, making the stylesheet same-origin:
build_staging:
stage: build
image: hugomods/hugo:exts
variables:
HUGO_ENV: "staging" # keeps analytics off the preview (see below)
STAGING_BASE_URL: "https://SITE--staging-HASH.web.app/"
script:
- hugo --baseURL "$STAGING_BASE_URL" --minify
artifacts:
paths: [public/]
expire_in: 1 day
only: [dev]
The channel hostname includes a random HASH that is stable for the life of the channel — pin it in STAGING_BASE_URL, and only update it if you delete and recreate the staging channel. This is a fragility I introduced deliberately since that hash is stable for the channel’s life. It is a manual step, but it keeps the staging build simple and fast. The alternative (deriving the URL dynamically) needs a two-pass build across two container images — not worth the complexity for a personal blog.
The deploy jobs
deploy: # live — main only
extends: .firebase_oidc
stage: deploy
dependencies: [build]
variables:
SERVICE_ACCOUNT_EMAIL: gitlab-deploy@PROJECT_ID.iam.gserviceaccount.com
WIF_AUDIENCE: "//iam.googleapis.com/projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/gitlab-pool/providers/gitlab"
script:
- firebase deploy --project PROJECT_ID --non-interactive --only hosting
only: [main]
staging: # preview channel — dev only
extends: .firebase_oidc
stage: deploy
dependencies: [build_staging]
variables:
SERVICE_ACCOUNT_EMAIL: gitlab-staging@PROJECT_ID.iam.gserviceaccount.com
WIF_AUDIENCE: "//iam.googleapis.com/projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/gitlab-pool/providers/gitlab"
script:
- firebase hosting:channel:deploy staging --project PROJECT_ID --expires 30d
only: [dev]
firebase hosting:channel:deploy staging creates/updates the channel and prints the URL (refreshing its 30-day expiry each run). Re-deploying the same channel name keeps the same hostname.
Keep analytics off staging
Because build_staging sets HUGO_ENV=staging, anything gated on hugo.Environment == "production" — for example a Google Analytics partial (see the consent post) — simply doesn’t render on the preview. Staging is a faithful visual copy that never pollutes your analytics.
Result
Every push to dev yields a shareable, throwaway preview of the exact site, built and deployed with short-lived, branch-scoped credentials. No staging server, no second project, no stored keys — just a branch and the same WIF you already set up for production.
Enjoy coding!