Advanced Gitlab pipeline for Nuxt2 app on Google App Engine

A gitlab pipeline for Nuxt2 on GAE that includes Environment variables, server target, Parts of AutoDevops for testing

  ยท   6 min read

Most tutorials that I found only show how to deploy simple static apps. The following article describes how to build a gitlab pipeline for more complicated nuxt v2 apps including

  • Environment variables
  • server target
  • Parts of AutoDevops for testing

The complete resulting .gitlab-ci.yaml can be found at the bottom of this article.

app.yaml and environment variables

First, to deploy an app to Google App Engine (GAE), we need an app.yaml file. A basic one can be copied from Nuxt’s official documentation.

GAE currently does not really support environment variables out of the box. But there is a way to still make it work as described in this article.

The goal is to use a different set of environment variables for production (master branch) then for all other branches.

This can be achieved in the following 4 steps.

Set placeholder variables in app.yaml

This might look as follows:

env_variables:
  HOST: '0.0.0.0'
  NODE_ENV: 'production'
  VARIABLE1: _VARIABLE1
  VARIABLE2: _VARIABLE2

Define Gitlab Environments

The gitlab environment defines which environment variables should be used. This is achieved by adding the following lines at the beginning of my .gitlab-ci.yaml:

variables:
  ENVIRONMENT: "dev"

workflow:
  rules:
    - if: '$CI_COMMIT_REF_NAME == "master"'
      variables:
        ENVIRONMENT: "prod"
    - if: '$CI_COMMIT_REF_NAME != "master"'
      when: always

These lines define the global variable ENVIRONMENT to “dev” by default and change it to “prod” if the current branch name is “master”.

Add Environment Variables to Gitlab

In your gitlab project navigate to “CI/CD settings -> Variable” and add values for VARIABLE1 and VARIABLE2. Each variable should be defined for each environment. Remember to tick the masked checkbox for passwords and other secrets.

Add Gitlab Job to replace Variable Placeholders

This can be done by adding the following job to our pipeline.

stages:
  - setup

set_app_variables:
  stage: setup
  environment: $ENVIRONMENT
  script:
    -  sed -i "s/_VARIABLE1/${VARIABLE1}/g" app.yaml
    -  sed -i "s/_VARIABLE2/${VARIABLE2}/g" app.yaml
    -  cat app.yaml
  artifacts:
    paths:
      - app.yaml
    expire_in: 1h

The job uses the script section to replace the placeholder values with sed. The values used depend on the value of the above defined ENVIRONMENT variable. The line cat app.yaml is only for debugging purposes and shows the resulting file in the job logs. Masked variable values will be obfuscated in the logs. The resulting app.yaml file with actual values is exposed as an artifact so that subsequent jobs can use it.

Build pipeline

Until now we only made sure that the right environment variables are used in GAE. The following section describes how to deploy the app to Google Cloud. As recommended here we will deploy the production and staging app on two separate Google cloud projects. In my case these projects already exist so I won’t go into the details of how to set them up.

Add a deploy service account

Has to be done for both Google Cloud projects. Before building the pipeline add a new service account gitlab-appengine with the roles described here. This account will be used when deploying the app to GAE. Generate a json key for this service account and save it as the environment variable ‘SERVICE_ACCOUNT’ in Gitlab under the corresponding environment.

Note: I removed all line breaks from the key file before saving it as an environment variable. Not sure if this is needed though.

Install dependencies

Add the following lines at the bottom of the .gitlab-ci.yaml:

install:
  stage: setup
  script:
    - yarn install --frozen-lockfile
  cache:
    key:
      files:
        - yarn.lock
    paths:
      - node_modules
  artifacts:
    paths:
      - node_modules
    expire_in: 1h

This job pulls cached node_modules if they exist already. Then it does a yarn install without changing yarn.lock This way we ensure that the packages we used locally are the same that are installed with gitlab. That way the pipeline stays reproducible. A more detailed explanation can be found in this article. node_modules are then saved to the cache again and exposed as artifacts for subsequent jobs to download.

Build

Add the stage “build” .gitlab-ci.yaml, then add the following lines at the bottom:

build:
  stage: build
  environment: $ENVIRONMENT
  dependencies:
    - install
    - set_app_variables
  script:
    - yarn run build
  artifacts:
    paths:
      - .nuxt/
    expire_in: 1h

The build step exposes the build directory which is needed to deploy an SSR application. Build variables can also be included here by again specifying the environment.

Deploy

Add the stage “deploy” .gitlab-ci.yaml, then add the following job at the bottom:

deploy:
  image: google/cloud-sdk:alpine
  stage: deploy
  dependencies:
    - build
    - set_app_variables
  environment: $ENVIRONMENT
  script:
    - gcloud auth activate-service-account --key-file $SERVICE_ACCOUNT
    - gcloud app deploy --quiet --project=$PROJECT_ID --service-account=gitlab-appengine@$PROJECT_ID.iam.gserviceaccount.com

The deploy step is based on this article. The improvement is saving SERVICE_ACCOUNT as a file. That way we can skip creating and deleting a temporary json file each time the job runs.

The SERVICE_ACCOUNT and PROJECT_ID variables are again both dependend on the current environment. That ensures deployment to diffrent Google Cloud projects for staging and production.

Deploy rules

If you want to include build and deploy only for two specific branches you could add the only keyword to both jobs specifying the branch names. I wanted to keep things a little more DRY so I added the following rules at the beginning of the .gitlab-ci.yaml

# Only include build and deploy steps if branch name is dev or master
.deploy_rules:
  rules:
    - if: '$CI_COMMIT_REF_NAME == "master" || $CI_COMMIT_REF_NAME == "dev"'

Then include the rules to each job with the following lines:

  rules:
    - !reference [.deploy_rules, rules]

Full yaml file:

The following file also includes some Auto DevOps features from gitlab.

# https://dev.to/mungell/google-cloud-app-engine-environment-variables-5990 https://dev.to/drakulavich/gitlab-ci-cache-and-artifacts-explained-by-example-2opi
# https://cloud.google.com/appengine/docs/flexible/roles#recommended_role_for_application_deployment
image: node:20

.deploy_rules:
  rules:
    - if: '($CI_COMMIT_REF_NAME == "master" || $CI_COMMIT_REF_NAME == "dev") && $CI_PIPELINE_SOURCE != "merge_request_event"'

variables:
  ENVIRONMENT: "dev"

workflow:
  rules:
    - if: '$CI_COMMIT_REF_NAME == "master"'
      variables:
        ENVIRONMENT: "prod"
    - if: '$CI_COMMIT_REF_NAME != "master"'
      when: always

stages:
  - setup
  - build
  - test
  - deploy

# https://gitlab.com/gitlab-org/gitlab/-/blob/master/lib/gitlab/ci/templates/Auto-DevOps.gitlab-ci.yml
include:
  # - template: Jobs/Code-Quality.gitlab-ci.yml
  - template: Jobs/Code-Intelligence.gitlab-ci.yml
  # - template: Jobs/Browser-Performance-Testing.gitlab-ci.yml
  - template: Jobs/Dependency-Scanning.gitlab-ci.yml
  - template: Jobs/SAST.gitlab-ci.yml
  - template: Jobs/Secret-Detection.gitlab-ci.yml

# https://about.gitlab.com/blog/2022/09/12/a-visual-guide-to-gitlab-ci-caching/
# Pull cached node_modules
# Install nodes packages without changing, Pulling
# new packages only happens if lockfile changed
# Push node_modules to cache
# Provide node_modules as artifacts for other jobs
install:
  stage: setup
  script:
    - yarn install --frozen-lockfile
  cache:
    key:
      files:
        - yarn.lock
    paths:
      - node_modules
  artifacts:
    paths:
      - node_modules
    expire_in: 1h

set_app_variables:
  stage: setup
  environment: $ENVIRONMENT
  script:
    - sed -i "s/_FB_API_KEY/${FB_API_KEY}/g" app.yaml
    - sed -i "s/_FB_PROJECT_ID/${FB_PROJECT_ID}/g" app.yaml
    - sed -i "s/_FB_MESSAGING_SENDER_ID/${FB_MESSAGING_SENDER_ID}/g" app.yaml
    - sed -i "s/_FB_APP_ID/${FB_APP_ID}/g" app.yaml
    - sed -i "s/_VPC_CONNECTOR/${VPC_CONNECTOR}/g" app.yaml
    - sed -i "s/_SQL_ID/${SQL_ID}/g" app.yaml
    - sed -i "s/_PGHOST/${PGHOST}/g" app.yaml
    - sed -i "s/_PGPORT/${PGPORT}/g" app.yaml
    - sed -i "s/_PGUSER/${PGUSER}/g" app.yaml
    - sed -i "s/_PGPASSWORD/${PGPASSWORD}/g" app.yaml
    - sed -i "s!_BROWSER_BASE_URL!${BROWSER_BASE_URL}!g" app.yaml
    - sed -i "s!_BREVO_API_KEY!${BREVO_API_KEY}!g" app.yaml
    - sed -i "s!_POSTHOG_API_KEY!${POSTHOG_API_KEY}!g" app.yaml
    - sed -i "s!_ZENDESK_SECRET!${ZENDESK_SECRET}!g" app.yaml
    - sed -i "s!_MAINTENANCE_START!${MAINTENANCE_START}!g" app.yaml
    - sed -i "s!_MAINTENANCE_END!${MAINTENANCE_END}!g" app.yaml
    - cat app.yaml
  artifacts:
    paths:
      - app.yaml
    expire_in: 1h

# jest:
#   stage: test
#   coverage: /All files[^|]*\|[^|]*\s+([\d\.]+)/
#   dependencies:
#     - install
#   script:
#     - yarn test:ci

build:
  stage: build
  environment: $ENVIRONMENT
  dependencies:
    - install
    - set_app_variables
  script:
    - yarn run build
  artifacts:
    paths:
      - .nuxt/
    expire_in: 1h
  rules:
    - !reference [.deploy_rules, rules]

deploy:
  image: google/cloud-sdk:alpine
  stage: deploy
  dependencies:
    - build
    - set_app_variables
  environment: $ENVIRONMENT
  script:
    - gcloud auth activate-service-account --key-file=$SERVICE_ACCOUNT
    - gcloud app deploy --quiet --project=$FB_PROJECT_ID --service-account=gitlab-appengine@$FB_PROJECT_ID.iam.gserviceaccount.com
  rules:
    - !reference [.deploy_rules, rules]