Continuous Deployment for Hugo websites

Updated June 2026 · 5 minute read
The following assume familiarity with Continuous Deployment practices, git, Hugo installed and an existing Hugo project, plus a Google Cloud / Firebase project and the 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

  1. Git + GitLab — repository and free CI/CD pipelines.
  2. 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.
  3. Firebase Hosting — free static hosting with automatic HTTPS.
  4. 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_TOKEN is ever stored.

Work-flow

  1. Work locally (hugo server -D).
  2. Commit when happy.
  3. Push to master.
  4. 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!

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.