gcloud CLI.Continuous Deployment minimises the time from a committed change to it being live, with no manual steps: push, and a pipeline tests, builds and deploys automatically. This post describes a free setup for a Hugo site, deploying to Firebase Hosting from GitLab CI.
Services and tools
- Git + GitLab — repository and free CI/CD pipelines.
- A Hugo Extended container image — we use the public
hugomods/hugo:exts(Extended is required for SCSS/Hugo Pipes). You can still build and pin your own image if you prefer strict reproducibility. - Firebase Hosting — free static hosting with automatic HTTPS.
- Workload Identity Federation — lets GitLab’s CI exchange a short-lived OIDC token for a Google Cloud access token, so no service-account key or
FIREBASE_TOKENis ever stored.
Work-flow
- Work locally (
hugo server -D). - Commit when happy.
- Push to
master. - Done — the pipeline builds and deploys automatically.
Step 1 — Git and a GitLab project
Initialise git (ignoring Hugo’s public/ output) and push to a new GitLab project:
printf '/public\nresources/_gen\n.hugo_build.lock\n' > .gitignore
git init && git add . && git commit -m "Initial commit"
git remote add origin https://gitlab.com/NAMESPACE/PROJECT.git
git push -u origin master
Note your GitLab project ID (Settings → General) — call it GITLAB_PROJECT_ID; the WIF condition below pins to it.
Step 2 — Firebase Hosting
Create a Firebase project, then from your site root:
npm install -g firebase-tools
firebase login
firebase init hosting # public dir: "public", single-page app: No
This writes firebase.json / .firebaserc. Commit them. Note your Firebase/GCP project ID (PROJECT_ID) and project number (PROJECT_NUMBER, from the Cloud console).
Step 3 — Keyless auth with Workload Identity Federation
Instead of a stored token, GitLab mints a per-job OIDC JWT; Google’s STS exchanges it for a short-lived token that impersonates a deploy service account. Run these gcloud commands once (with your project set):
# Enable the APIs
gcloud services enable iamcredentials.googleapis.com sts.googleapis.com firebasehosting.googleapis.com
# 1. A workload identity pool + an OIDC provider trusting gitlab.com,
# pinned to YOUR GitLab project so no other repo can use it.
gcloud iam workload-identity-pools create gitlab-pool --location=global
gcloud iam workload-identity-pools providers create-oidc gitlab \
--location=global --workload-identity-pool=gitlab-pool \
--issuer-uri="https://gitlab.com" \
--attribute-mapping="google.subject=assertion.sub,attribute.project_id=assertion.project_id" \
--attribute-condition="assertion.project_id == 'GITLAB_PROJECT_ID'"
# 2. A deploy service account with the Firebase Hosting roles.
gcloud iam service-accounts create gitlab-deploy --display-name="GitLab CI Firebase deploy"
for ROLE in roles/firebasehosting.admin roles/firebase.viewer; do
gcloud projects add-iam-policy-binding PROJECT_ID \
--member="serviceAccount:gitlab-deploy@PROJECT_ID.iam.gserviceaccount.com" --role="$ROLE"
done
# 3. Allow only YOUR GitLab project's pipelines to impersonate that SA.
gcloud iam service-accounts add-iam-policy-binding gitlab-deploy@PROJECT_ID.iam.gserviceaccount.com \
--role="roles/iam.workloadIdentityUser" \
--member="principalSet://iam.googleapis.com/projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/gitlab-pool/attribute.project_id/GITLAB_PROJECT_ID"
That principalSet binding is the whole trust boundary: only OIDC tokens from your GitLab project can assume the deploy account. For tighter control you can map additional claims (e.g. assertion.ref) and restrict to the master branch.
Step 4 — The pipeline (.gitlab-ci.yml)
stages: [build, deploy]
build:
stage: build
image: hugomods/hugo:exts # public Hugo Extended image
variables:
HUGO_ENV: "production"
script:
- hugo --minify
artifacts:
paths: [public/]
expire_in: 1 day
only: [master]
deploy:
stage: deploy
image: node:22-slim
dependencies: [build]
id_tokens: # GitLab mints the OIDC JWT
GITLAB_OIDC_TOKEN:
aud: https://gitlab.com
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:
- 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
- firebase deploy --project PROJECT_ID --non-interactive --only hosting --message "Pipeline $CI_PIPELINE_ID, sha1 $CI_COMMIT_SHA"
only: [master]
firebase-tools reads the external_account credential file from GOOGLE_APPLICATION_CREDENTIALS and performs the STS exchange itself — there is nothing to docker login and no gcloud needed in the job. One caveat: do not leave a FIREBASE_TOKEN variable set in GitLab, as firebase-tools prefers it over the WIF credentials.
Why keyless
With this setup there are no long-lived secrets in GitLab. Each job receives a token that lives for the job and is scoped, by the principalSet binding, to exactly your project (and, optionally, branch). A leaked CI log cannot be replayed, and there is no key to rotate.
Push to master and watch the pipeline under CI/CD → Pipelines. When it passes, your changes are live on Firebase Hosting. From here you can add a dev-branch job that publishes a Firebase preview channel for staging — see the companion post Keyless Hugo staging: GitLab OIDC + Firebase preview channels — but the above is the complete, free, keyless core.
Enjoy coding!