angulartypescriptgoogle-app-enginetsconfigapp.yaml

Angular 17 web app deploy to Google App Engine not working


I have an Angular (v17.2) web app project that is building and running locally just fine, but when I try to deploy (gcloud app deploy) it to Google's App Engine, the app does is not served properly.

Apparently GAE can build it, but when index.html is served I get "404 Not Found"

Error: Not Found screen

The one and only GAE's build log that intrigued me reads the following:

Using config appstart.Config{Runtime:"nodejs22", Entrypoint:appstart.Entrypoint{Type:"Default", Command:"/serve", WorkDir:""}, MainExecutable:""}

MainExecutable:"" ... is that right?


I read a lot of documentation about the app.yaml and both angular.json and tsconfig.json config files and their possible attributes, but I could not find what else there might be that I can still be doing wrong.

I even created a new Angular example project (with ng new) and tried to deploy it with the absolute defaults, but that also had the same effect.


My config files are as follows:

app.yaml:

runtime: nodejs22

service: [app-name]

handlers:

- url: /(.*\.css)
  static_files: dist/[app-name]/\1
  upload: dist/[app-name]/(.*\.css)

- url: /(.*\.html)
  static_files: dist/[app-name]/\1
  upload: dist/[app-name]/(.*\.html)

# Serve the root file
- url: /
  static_files: dist/[app-name]/index.html
  upload: dist/[app-name]/index.html

# Catch-all rule, responsible from handling Angular application routes (deeplinks).
- url: /.*
  static_files: dist/[app-name]/index.html
  upload: dist/[app-name]/index.html
  login: admin
  redirect_http_response_code: 301
  secure: always

Is there anything wrong the handlers' values and/or order?


angular.json:

{
  "$schema": "./node_modules/@angular/cli/lib/config/schema.json",
  "version": 1,
  "newProjectRoot": "projects",
  "projects": {
    "adm-portal": {
      "projectType": "application",
      "schematics": {},
      "root": "",
      "sourceRoot": "src",
      "prefix": "app",
      "architect": {
        "build": {
          "builder": "@angular-devkit/build-angular:browser",
          "options": {
            "outputPath": "dist/[app-name]",
            "index": {
              "input": "src/index.html",
              "output": "index.html"
            },
            "main": "src/main.ts",
            "polyfills": [
              "zone.js"
            ],
            "tsConfig": "tsconfig.app.json",
            "assets": [
              "src/assets"
            ],
            "styles": [
              "src/styles.css"
            ],
            "scripts": [],
            "allowedCommonJsDependencies": [
              "sweetalert2"
            ]
          },
          "configurations": {
            "production": {
              "budgets": [
                {
                  "type": "initial",
                  "maximumWarning": "500kb",
                  "maximumError": "1mb"
                },
                {
                  "type": "anyComponentStyle",
                  "maximumWarning": "2kb",
                  "maximumError": "4kb"
                }
              ],
              "outputHashing": "all"
            },
            "staging": {
              "optimization": false,
              "extractLicenses": false,
              "sourceMap": true,
              "fileReplacements": [
                {
                  "replace": "src/environments/environment.ts",
                  "with": "src/environments/environment.staging.ts"
                }
              ]
            },
            "development": {
              "optimization": false,
              "extractLicenses": false,
              "sourceMap": true,
              "fileReplacements": [
                {
                  "replace": "src/environments/environment.ts",
                  "with": "src/environments/environment.development.ts"
                }
              ]
            }
          },
          "defaultConfiguration": "production"
        },
        "serve": {
          "builder": "@angular-devkit/build-angular:dev-server",
          "configurations": {
            "production": {
              "buildTarget": "[app-name]:build:production"
            },
            "staging": {
              "buildTarget": "[app-name]:build:staging"
            },
            "development": {
              "buildTarget": "[app-name]:build:development"
            }
          },
          "defaultConfiguration": "development"
        },
        "extract-i18n": {
          "builder": "@angular-devkit/build-angular:extract-i18n",
          "options": {
            "buildTarget": "[app-name]:build"
          }
        },
        "test": {
          "builder": "@angular-devkit/build-angular:karma",
          "options": {
            "polyfills": [
              "zone.js",
              "zone.js/testing"
            ],
            "tsConfig": "tsconfig.spec.json",
            "assets": [
              "src/favicon.ico",
              "src/assets"
            ],
            "styles": [
              "src/styles.css"
            ],
            "scripts": []
          }
        }
      }
    }
  },
  "cli": {
    "analytics": false
  }
}

tsconfig.json:

{
  "compileOnSave": false,
  "compilerOptions": {
    "outDir": "./dist/out-tsc",
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "noImplicitOverride": true,
    "noPropertyAccessFromIndexSignature": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "skipLibCheck": true,
    "esModuleInterop": true,
    "sourceMap": true,
    "declaration": false,
    "experimentalDecorators": true,
    "moduleResolution": "node",
    "importHelpers": true,
    "target": "ES2022",
    "module": "ES2022",
    "useDefineForClassFields": false,
    "lib": [
      "ES2022",
      "dom"
    ]
  },
  "angularCompilerOptions": {
    "enableI18nLegacyMessageIdFormat": false,
    "strictInjectionParameters": true,
    "strictInputAccessModifiers": true,
    "strictTemplates": true
  }
}


tsconfig.app.json:

{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "outDir": "./out-tsc/app",
    "types": []
  },
  "files": [
    "src/main.ts"
  ],
  "include": [
    "src/**/*.d.ts"
  ]
}

package.json:

{
  "name": "[app-name]",
  "version": "0.0.0",
  "scripts": {
    "ng": "ng",
    "start": "ng serve",
    "build": "ng build",
    "watch": "ng build --watch --configuration development",
    "test": "ng test"
  },
  "private": true,
  "dependencies": {
    "@abacritt/angularx-social-login": "^2.2.0",
    "@angular/animations": "^17.2.0",
    "@angular/common": "^17.2.0",
    "@angular/compiler": "^17.2.0",
    "@angular/core": "^17.2.0",
    "@angular/forms": "^17.2.0",
    "@angular/platform-browser": "^17.2.0",
    "@angular/platform-browser-dynamic": "^17.2.0",
    "@angular/router": "^17.2.0",
    "rxjs": "~7.8.0",
    "sweetalert2": "^11.12.3",
    "tslib": "^2.3.0",
    "zone.js": "~0.14.3"
  },
  "devDependencies": {
    "@angular-devkit/build-angular": "^17.2.0",
    "@angular/cli": "^17.2.0",
    "@angular/compiler-cli": "^17.2.0",
    "@types/jasmine": "~5.1.0",
    "jasmine-core": "~5.1.0",
    "karma": "~6.4.0",
    "karma-chrome-launcher": "~3.2.0",
    "karma-coverage": "~2.2.0",
    "karma-jasmine": "~5.1.0",
    "karma-jasmine-html-reporter": "~2.1.0",
    "typescript": "~5.3.2"
  }
}


.gcloudignore:

# This file specifies files that are *not* uploaded to Google Cloud
# using gcloud. It follows the same syntax as .gitignore, with the addition of
# "#!include" directives (which insert the entries of the given .gitignore-style
# file at that point).
#
# For more information, run:
#   $ gcloud topic gcloudignore

.gcloudignore

# If you would like to upload your .git directory, .gitignore file
# or files from your .gitignore file, remove the corresponding line below:
.git
.gitignore
#!include:.gitignore

# Ensure the dist directory is included (where build output goes)
!dist/

# backup files typically ending with a tilde (~)
*~

# node_modules directory, which contains package dependencies
/node_modules/

# e2e directory used for end-to-end tests
/e2e/

# hidden files and directories (e.g., .env, .gitconfig) and configuration files
^(.*/)?\..*$

# all JSON files
#^(.*/)?.*\.json$

# all Markdown files (README/text files)
^(.*/)?.*\.md$

# all YAML files
**/*.yaml$

# except for `app.yaml`
!app.yaml

# LICENSE file
^LICENSE

If there's any other info/log you may need to analyze this better, please let me know.

Any and all help is greatly appreciated!


Solution

  • After an entire week trying to debug this issue, I was able to find the problem.

    It turns out at some point I got to the wrong conclusion that Google App Engine would build the project on its own whenever gcloud app deploy was issued. For that reason, I figured I could comment the line in .gcloudignore that told it to consider the dist folder in the deploy upload (!dist/). In parts I thank @Deivid Drenkhan for his question from years ago: his mention of the skip_files part in his file poked me to go check mine.

    One other thing that I changed, but am unsure whether it was necessary or not, was the moduleResolution element in the tsconfig.json file from "node" to "bundler". Apparently, that is what is used by default when you create a new project in Angular 18, so I thought of going with it. Also, after reading the documentation for it, it made sense. Since so many points were investigated and adjusted, there is a chance that other things were incorrect, or at least non ideal, that contributed to the problem.

    Maybe it is also worth mentioning that by deploying to a Flex environment, it is possible to connect via SSH to the VM where your application is running. That was going to be my next attempt in case I still could not understand what was causing the problem.


    My final app.yaml file looks like this:

    runtime: nodejs22
    
    service: [app-name]
    
    handlers:
    
    - url: /([^.]+)/?$  # URls with no dot in them (e.g. /index, /about, etc)
      static_files: dist/index.html
      upload: dist/index.html
    
    # Serve index.html for the root URL ('/')
    - url: /
      static_files: dist/index.html
      upload: dist/index.html
      secure: always
      redirect_http_response_code: 301
      login: admin
    
    # Serve CSS files from the root 'dist/' directory
    - url: /(.+)\.css
      static_files: dist/\1.css
      upload: dist/.+\.css
    
    # Serve JavaScript files from the root 'dist/' directory
    - url: /(.+)\.js
      static_files: dist/\1.js
      upload: dist/.+\.js
    
    # Serve HTML files from the root 'dist/' directory
    - url: /(.+)\.html
      static_files: dist/\1.html
      upload: dist/.+\.html
    
    # Serve files from the 'assets/' directory
    - url: /assets/(.*)
      static_files: dist/assets/\1
      upload: dist/assets/(.*)
    
    # Catch-all rule to serve index.html for any path not matched above
    - url: /.*
      static_files: dist/index.html
      upload: dist/index.html
      secure: always
      redirect_http_response_code: 301
      login: admin
    




    Improvement suggestions, hints, comments and/or corrections to something I eventually said wrong are still and always welcome.