Keyless Hugo staging: GitLab OIDC + Firebase preview channels

June 2026 · 5 minute read
This builds directly on Continuous Deployment for Hugo websites — the keyless GitLab OIDC + Workload Identity Federation setup. Read that first; here we add a staging preview on a separate branch.

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:

BranchPipelineResult
push devbuild → staginga Firebase preview channel at https://SITE--staging-HASH.web.app (auto-expires)
merge to mainbuild → deploythe 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!

Above opinions and any mistakes are my own. I am not affiliated in any way with companies, or organizations mentioned above. The code samples provided are licensed under the Apache 2.0 License and rest content of this page is licensed under the Creative Commons Attribution 3.0 License, except if noted otherwise.