Skip to content

App Engine Deployment

When to use

Use this script when deploying a Node.js application to Google App Engine and you want a repeatable, safe deployment process that integrates with git. The script enforces a clean working tree, derives a version name from the current branch, deploys without promoting, and tags the deployment in git.

The pattern

Build a CLI deploy script that:

  1. Guards against dirty state — reject deployment when there are uncommitted changes
  2. Derives a version name — use the current git branch as the default App Engine version identifier
  3. Deploys without promoting — use --no-promote so the new version receives no traffic until explicitly promoted
  4. Tags the deployment — create a git tag so the exact commit is traceable to each deployed version

Deploying

Deploy the current branch interactively:

sh
npm run deploy
# Deploys version "feature-my-feature" (from branch name)
# gcloud prompts for confirmation

Deploy a named version silently for CI:

sh
npm run deploy:quiet -- staging
# Deploys version "staging" with no prompts

After deploying, promote a version to receive traffic:

sh
gcloud app versions migrate staging \
  --project=my-project-id

List all deployed versions:

sh
gcloud app versions list --project=my-project-id

Defining the deploy script

javascript
#!/usr/bin/env node
import { execSync, execFileSync } from "node:child_process";
import { program } from "commander";

const PROJECT_ID = process.env.GCLOUD_PROJECT
  ?? "my-project-id";

// Guard: no uncommitted changes
const diff = execSync("git diff", { encoding: "utf-8" }).trim();
if (diff) {
  console.error(
    "Uncommitted changes detected. Commit before deploying."
  );
  process.exit(1);
}

const defaultVersion = execSync(
  "git branch --show-current",
  { encoding: "utf-8" },
).trim().replaceAll("/", "-");

program.name("deploy");
program.description("Deploy to App Engine");
program.argument("[version]", "version name", defaultVersion);
program.option("--quiet", "suppress interactive prompts", false);

program.action(async (version, options) => {
  const args = [
    "app", "deploy", "app.yaml",
    "--no-promote",
    `--project=${PROJECT_ID}`,
    `--version=${version}`,
  ];
  if (options.quiet) args.push("--quiet");
  
  try {
    execFileSync("gcloud", args, { stdio: "inherit" });
    tagDeployment(version);
  } catch {
    console.error("Deployment failed.");
    process.exit(1);
  }
});

program.parse();

function tagDeployment(version) {
  const tag = `deployments/${version}`;
  execSync(`git tag ${tag} --force`);
  console.log();
  console.log(`Tag created: ${tag}`);
}

The app.yaml file

Every App Engine app needs app.yaml in the project root:

yaml
runtime: nodejs22
instance_class: F1

handlers:
  - url: /.*
    script: auto

Package script

Add the deploy command to package.json so the team uses a consistent entry point:

json
{
  "scripts": {
    "deploy": "node scripts/deploy.js",
    "deploy:quiet": "node scripts/deploy.js --quiet"
  }
}

Trade-offs

  • --no-promote is safer but adds a manual step to promote. In CI, follow the deploy with an explicit gcloud app versions migrate command if you want automatic traffic shifting.
  • Branch name as version is convenient but can produce awkward names from deep branch paths. The replaceAll call converts slashes to hyphens, but very long branch names may hit the App Engine version name length limit.
  • Git tag with --force moves the tag if it already exists, which loses the previous tag position. This is intentional — the tag always points to the latest deploy of that version name. If you need history, use annotated tags or a deploy log instead.
  • Uncommitted change guard catches dirty state but does not check for unpushed commits. Add a git diff origin check if you need to enforce that code is pushed before deployment.
  • execSync / execFileSync blocks the process, which is appropriate for a CLI deploy script. For server-side or concurrent use, switch to the async spawn alternatives.