Define a Node.js App Engine application in Terraform

Leejjon
9 min readSep 1, 2023

What are we going to do

Create a Node.js hello world application and deploy it on App Engine with Terraform. We will create the project from scratch and supply every command you need to run along the way.

What made me want to write this?

With App Engine it has been extremely easy to deploy web apps in the cloud. In my opinion it has been a lot easier than configuring AWS Beanstalk projects or even AWS Lambda. It’s really nice that you don’t even need to know about docker. Only Vercel gets close on developer friendliness level.

You can deploy your first application using the great interactive tutorials or using the gcloud CLI. If you start connecting your application with other Google Cloud Platform services, it’s easy to do it by hand (in the Google Cloud Console web UI).

As time passes it becomes more difficult to remember all the things you’ve configured. Especially databases. I was already learning to use Terraform on AWS for my job, so I tried to define my App Engine project and other required resources in Terraform.

There are currently not a lot of guides to define App Engine projects in Terraform besides the official Terraform docs (Vercel does this a lot better). And those that I found were not detailed at all and mostly targeting the App Engine runtimes that I do not use (Go / Python).

After many hours of reading the docs and reverse engineering Terraform configurations from random projects on GitHub that use App Engine and Terraform, I managed to create a project that deploys new versions nicely.

Requirements

  • You need a Google Cloud account with billing set up (you can use the free trial credit you get when signing up).
  • You need to have the Google Cloud SDK installed.
  • You need to have Terraform installed.
  • A bash terminal. I’m using Ubuntu 22.04 but Mac OS is fine too and even Windows has a subsystem for Linux nowadays.

Create a project in Google cloud

Go to https://console.cloud.google.com and create a new project:

Warning: If you pick a Project Name that is already being used as a Project ID by somebody else, Google Cloud will will be setting it to something random as custom-program-394415. Click on the blue EDIT button to give it a better name.

Instructions

Create a project folder, with a Terraform folder that contains a main.tf file:

mkdir ts-appengine-terraform
cd ts-appengine-terraform
mkdir terraform
cd terraform
touch main.tf

Paste the following content in the main.tf and fill in the project id and bucket name placeholders:

terraform {
required_providers {
google = {
source = "hashicorp/google"
version = "4.51.0"
}
}
# backend "gcs" {
# bucket = "<your_bucket_name>"
# prefix = "ts-appengine-terraform/state"
# }
}

provider "google" {
project = var.project_name
region = "us-central1"
}

resource "google_storage_bucket" "terraform_state" {
name = "<your_bucket_name>"
force_destroy = false
location = "US"
storage_class = "STANDARD"
versioning {
enabled = true
}
}

Now create a variables.tf file in which we will put our project name:

variable "project_name" {
type = string
default = "<your project id here>"
}

Initializing gcloud and Terraform

  • Run: gcloud init
  • Choose initialize (or re-initialize) the configuration
  • Click enter project id

Now that gcloud has been initialized, we can let Terraform use the same authentication connection if we enable User Default Credentials as described in the Terraform docs on Authentication. You can run:

gcloud auth application-default login --project your_project_id

Now into your terraform folder, run:

terraform init
terraform plan

If all seems well, we can run:

terraform apply

You can check in the cloud console if Terraform managed to create the bucket:

Storing our state file in the cloud (optional)

We now created infrastructure from our local terraform script. Terraform stored a .tfstate file on our local machine to keep track of the infrastructure it manages.

I’ve read that it’s bad practice to commit tfstate files in Git because they might contain passwords to database connections.

The upcoming instructions will explain how to store the tfstate file in the storage bucket we just created in the cloud. This is also explained in the Google Cloud docs.

Remove the comments of the backend “gcs” block in the main.tf and make sure it points to your bucket:

terraform {
required_providers {
google = {
source = "hashicorp/google"
version = "4.51.0"
}
}
backend "gcs" {
bucket = "ts-appengine-terraform-tfstate"
prefix = "ts-appengine-terraform/state"
}
}

provider "google" {
project = var.project_name
region = "us-central1"
}

resource "google_storage_bucket" "terraform_state" {
name = "ts-appengine-terraform-tfstate"
force_destroy = false
location = "US"
storage_class = "STANDARD"
versioning {
enabled = true
}
}

Now we have to re-run the init command to change the tfstate file to the bucket. You’ll get a prompt about importing the local Terraform state into the bucket.

After this you can run a terraform apply and it should tell you nothing changed.

Adding an App Engine application

Put the following code in the main.tf:

resource "google_app_engine_application" "ts-appengine-app" {
project = var.project_name
location_id = "us-central"
}

resource "google_app_engine_application_url_dispatch_rules" "ts-appengine-app-dispatch-rules" {
dispatch_rules {
domain = "*"
path = "/*"
service = "default"
}
}

Running terraform apply will create an App Engine project with no deployments yet.

Creating the simplest Node.js application

Run the following commands:

# Go back to your project folder (outside the terraform folder)
cd ..

# Create a folder for the Node.js app
mkdir app
touch index.js
npm init
npm install express

Paste the following code in your newly created index.js file:

'use strict';

const express = require('express');
const app = express();

app.use((req, res) => {
res.status(200).send('Hello, world!');
});
// Start the server
const PORT = process.env.PORT || 8080;
app.listen(PORT, () => {
console.log(`App listening on port ${PORT}`);
console.log('Press Ctrl+C to quit.');
});

And make sure your script start script looks like this:

{
"name": "runtime",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"start": "node index.js",
"gcp-build": ""
},
"author": "",
"license": "ISC",
"dependencies": {
"express": "^4.18.2"
}
}

You can try running locally with npm start and browsing to localhost:8080

Deploying the application code via gcloud

You can choose to only have the definition of the app engine application and resources that it needs in Terraform, but still deploy the actual code with the gcloud command (which could be ran from a cloud build pipeline).

For this we need to specify a few things in an app.yaml file in the app folder:

runtime: nodejs20
service: default
env: standard
instance_class: F1
handlers:
- url: '.*'
script: auto
secure: always
automatic_scaling:
min_idle_instances: automatic
max_idle_instances: automatic
min_pending_latency: automatic
max_pending_latency: automatic

It works like this:

It results in a version being deployed in App Engine:

And when we click the version link we see that our Hello World! application runs fine:

Deploying the application code via Terraform

According to the Terraform docs, you can define App Engine versions in Terraform, so let’s try that out. You can remove the app.yaml file (and the auto-generated .gcloudignore) we just created.

First we need to zip our code. Apparently the gcloud app deploy command zips files and stores them in a storage bucket under the hood, before creating an actual App Engine version.

So first we need Terraform to create a zip file and a storage bucket by adding the following to the main.tf:

resource "google_storage_bucket" "app" {
name = "${var.project_name}-${random_id.app.hex}"
location = "US"
force_destroy = true
versioning {
enabled = true
}
}

resource "random_id" "app" {
byte_length = 8
}

data "archive_file" "function_dist" {
type = "zip"
source_dir = "../app"
output_path = "../app/app.zip"
}

resource "google_storage_bucket_object" "app" {
name = "app.zip"
source = data.archive_file.function_dist.output_path
bucket = google_storage_bucket.app.name
}

You can run terraform apply and verify in the cloud console that indeed a zip file ended up in a new storage bucket:

Now we can add an app version to the main.tf file:

resource "google_app_engine_standard_app_version" "latest_version" {

version_id = var.deployment_version
service = "default"
runtime = "nodejs20"

entrypoint {
shell = "node index.js"
}

deployment {
zip {
source_url = "https://storage.googleapis.com/${google_storage_bucket.app.name}/${google_storage_bucket_object.app.name}"
}
}

instance_class = "F1"

automatic_scaling {
max_concurrent_requests = 10
min_idle_instances = 1
max_idle_instances = 3
min_pending_latency = "1s"
max_pending_latency = "5s"
standard_scheduler_settings {
target_cpu_utilization = 0.5
target_throughput_utilization = 0.75
min_instances = 0
max_instances = 4
}
}
noop_on_destroy = true
delete_service_on_destroy = true
}

I recommend adding a variable called deployment_version to the variables.tf so we can make this dynamic later:

variable "deployment_version" {
type = string
}

You can run the following command to deploy a new version 2:

terraform apply -var="deployment_version=2"

You’ll notice that this has created a second deployment.

But something is wrong, the traffic wasn’t automatically promoted to the new version. The previous gcloud app deploy command automatically points all traffic to the new App Engine version (unless you use the --no-promoteflag).

I looked in the Terraform docs and there does not seem to find a way to set something like “promote: true” in the definition of a google_app_engine_standard_app_version resource.

They do have a traffic splitting definition, but that only works if you specify both the old and new App Engine versions in the main.tf file. I don’t want to add a new App Engine version to the main.tf file every time I do a new deployment (that would cause spam in the git commit history).

Combining Terraform and gcloud

The gcloud command has a separate command to point traffic to a certain App Engine version. So I guess we’ll have to glue Terraform and gcloud commands together with some bash.

In the root of the project, create a scripts folder. In there create a deploy.sh script. Apply chmod +x deploy.shon the file to be able to run the script. This bash code below will:

  • Generate a version id based on a timestamp
  • Run Terraform to deploy a new App Engine version
  • Point traffic towards that version using gcloud
cd ../app || exit
npm install

dateString=$(date +%s)

cd ../terraform || exit
terraform apply -auto-approve -var="deployment_version=${dateString}"

cd ../scripts || exit

# Point 100% of the traffic to the new version
gcloud app services set-traffic default --splits=$dateString=1 --quiet

You can find the full code that includes this script on GitHub.

Every time you run this script a new App Engine version is created, without deleting the old one. This is nice because if you discover something is wrong in your new version, you can simply point the traffic back to an old version that did work well.

Conclusion

Personally, I will use Terraform to define my App Engine application, database and any other services that my application depends on. But I will not use the google_app_engine_standard_app_version resources.

I will simply keep using the gcloud app deploy --version=x command for deploying code changes in my Node.js project. The reason here is that app engine can have multiple services. If I want to make a change in only one service, I can just do that without deploying every service. Each service could then have a separate CI/CD pipeline that only deploys that service using gcloud.

  • Thank you for reading!
  • Leave a comment if you have questions.
  • Follow me on Medium/Twitter/LinkedIn if you want to read more original programming content.

--

--

Leejjon

Java/TypeScript Developer. Interested in web/mobile/backend/database/cloud. Freelancing, only interested in job offers from employers directly. No middle men.