DEV Community

Cover image for 🚀Deploying a Node.js App to Google Cloud VM with GitHub Actions CI/CD Setup
Latchu@DevOps
Latchu@DevOps

Posted on

🚀Deploying a Node.js App to Google Cloud VM with GitHub Actions CI/CD Setup

🧩 Introduction

This guide walks you through building a complete CI/CD pipeline that deploys a Node.js app from GitHub to a Google Compute Engine (GCE) Virtual Machine.

  1. Every time you push code to the main branch:
  2. GitHub Actions builds & tests your code
  3. Creates a zip artifact
  4. Securely copies it to your GCE VM
  5. Deploys and restarts the app using systemd
  6. Validates health automatically

You’ll end up with a zero-click deployment process.


⚙️ Step 1 — Create a Sample Node.js App

Create your project:

mkdir sample-gce-app && cd sample-gce-app
npm init -y
Enter fullscreen mode Exit fullscreen mode

Add a minimal web server:

mkdir src
cat > src/index.js <<'EOF'
const http = require('http');
const PORT = process.env.PORT || 3000;

const server = http.createServer((req, res) => {
  if (req.url === '/health') {
    res.writeHead(200, {'Content-Type': 'application/json'});
    res.end(JSON.stringify({status: 'ok'}));
  } else {
    res.writeHead(200);
    res.end('Hello from GCE VM 🚀');
  }
});

server.listen(PORT, () => console.log(`Server running on port ${PORT}`));
EOF
Enter fullscreen mode Exit fullscreen mode

Add package.json scripts:

npm install
npm set-script start "node src/index.js"
npm set-script test "echo 'No tests yet'"
Enter fullscreen mode Exit fullscreen mode

Then create a package-lock.json:

npm install
Enter fullscreen mode Exit fullscreen mode

✅ Commit both files — package.json and package-lock.json.

g1

You can use below source code repository

https://github.com/kohlidevops/github-action-project1


☁️ Step 2 — Create a Google Cloud VM

g3

Go to Google Cloud Console → Compute Engine → Create VM Instance

Choose:

  • OS: Ubuntu 22.04 LTS
  • Machine: e2-micro (for demo)
  • Allow HTTP/HTTPS traffic

Generate a deploy key:

ssh-keygen -t rsa -b 4096 -f ~/.ssh/gcpdeploy_key
Enter fullscreen mode Exit fullscreen mode

Copy the public key (~/.ssh/gcpdeploy_key.pub) into the VM → Edit → SSH Keys.

Note the external IP.

g2


🧰 Step 3 — Prepare Your VM

SSH into your VM:

ssh -i ~/.ssh/gcpdeploy_key ubuntu@<your-external-ip>
Enter fullscreen mode Exit fullscreen mode

Install dependencies:

curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -
sudo apt install -y nodejs unzip
Enter fullscreen mode Exit fullscreen mode

Create app directory:

sudo mkdir -p /opt/myapp
sudo chown -R ubuntu:ubuntu /opt/myapp
Enter fullscreen mode Exit fullscreen mode

⚙️ Step 4 — Create a systemd Service

Create /etc/systemd/system/myapp.service:

[Unit]
Description=Sample Node App
After=network.target

[Service]
User=ubuntu
WorkingDirectory=/opt/myapp/current
ExecStart=/usr/bin/node /opt/myapp/current/src/index.js
Restart=always
RestartSec=10
Environment=PORT=3000
StandardOutput=append:/var/log/myapp.log
StandardError=append:/var/log/myapp.err

[Install]
WantedBy=multi-user.target
Enter fullscreen mode Exit fullscreen mode

Enable service:

sudo systemctl daemon-reload
sudo systemctl enable myapp
Enter fullscreen mode Exit fullscreen mode

🔐 Step 5 — Configure GitHub Secrets

Go to Settings → Secrets and variables → Actions → New repository secret

Secret Name Example Value
GCE_SSH_PRIVATE_KEY (contents of ~/.ssh/gcpdeploy_key)
GCE_SSH_USER ubuntu
GCE_SSH_HOST 34.xxx.xxx.xxx
GCE_REMOTE_DIR /opt/myapp
GCE_SSH_PORT 22

g4


⚙️ Step 6 — GitHub Actions Workflow

Create .github/workflows/ci-cd.yml:

name: CI/CD → GCE VM

on:
  push:
    branches:
      - main

jobs:
  build-test-deploy:
    runs-on: ubuntu-latest
    env:
      REMOTE_DIR: ${{ secrets.GCE_REMOTE_DIR }}
      SSH_USER: ${{ secrets.GCE_SSH_USER }}
      SSH_HOST: ${{ secrets.GCE_SSH_HOST }}
      SSH_PORT: ${{ secrets.GCE_SSH_PORT || '22' }}

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '18'

      - name: Verify lock file
        run: |
          if [ ! -f package-lock.json ]; then
            echo "No lockfile found — generating one."
            npm install
          fi

      - name: Install dependencies
        run: npm ci --prefer-online --no-audit

      - name: Run tests
        run: npm test

      - name: Create deployment artifact
        run: |
          mkdir -p build
          cp package*.json build/
          cp -r src build/
          cd build && zip -r ../artifact.zip .
          ls -lh ../artifact.zip

      - name: Configure SSH key
        run: |
          mkdir -p ~/.ssh
          echo "${{ secrets.GCE_SSH_PRIVATE_KEY }}" > ~/.ssh/id_rsa
          chmod 600 ~/.ssh/id_rsa
          ssh-keyscan -p ${{ env.SSH_PORT }} ${{ env.SSH_HOST }} >> ~/.ssh/known_hosts

      - name: Copy artifact to GCE VM
        uses: appleboy/scp-action@v0.1.7
        with:
          host: ${{ env.SSH_HOST }}
          username: ${{ env.SSH_USER }}
          port: ${{ env.SSH_PORT }}
          key: ${{ secrets.GCE_SSH_PRIVATE_KEY }}
          source: "artifact.zip"
          target: "${{ env.REMOTE_DIR }}"

      - name: Deploy & restart service
        uses: appleboy/ssh-action@v0.1.8
        with:
          host: ${{ env.SSH_HOST }}
          username: ${{ env.SSH_USER }}
          port: ${{ env.SSH_PORT }}
          key: ${{ secrets.GCE_SSH_PRIVATE_KEY }}
          script: |
            set -e
            echo "Deploying new version to ${REMOTE_DIR}"
            cd "${REMOTE_DIR}"
            mkdir -p releases
            TIMESTAMP=$(date +%s)
            mkdir -p releases/$TIMESTAMP
            unzip -o artifact.zip -d releases/$TIMESTAMP
            rm -rf current_prev || true
            [ -d current ] && mv current current_prev || true
            mv releases/$TIMESTAMP current
            cd current
            npm ci --omit=dev || npm install --omit=dev
            sudo systemctl daemon-reload
            sudo systemctl restart myapp || (echo "Restart failed, rolling back..." && sudo systemctl stop myapp && rm -rf current && mv current_prev current && sudo systemctl start myapp)
            sleep 3
            curl -f http://localhost:3000/health || echo "Health check failed"
Enter fullscreen mode Exit fullscreen mode

🧾 Step 7 — Trigger a Deployment

Commit and push:

git add .
git commit -m "Enable full CI/CD deployment"
git push origin main
Enter fullscreen mode Exit fullscreen mode

GitHub Actions will:

  1. Checkout & build
  2. Install deps from package-lock.json
  3. Run tests
  4. Zip app
  5. Upload via SCP
  6. Unzip on GCE
  7. Restart myapp.service
  8. Validate health

g5


🧩 Step 8 — Verify on VM

Check service:

sudo systemctl status myapp
Enter fullscreen mode Exit fullscreen mode

View logs:

tail -f /var/log/myapp.log
Enter fullscreen mode Exit fullscreen mode

App should be live at:

http://<your-external-ip>:3000
Enter fullscreen mode Exit fullscreen mode

🧠 Rollback Logic Built-In

If the new version fails to start, the script automatically:

  • Moves current_prev back to current
  • Restarts the last working version

⚡ Bonus Improvements

✅ Add npm caching:

- name: Cache npm modules
  uses: actions/cache@v4
  with:
    path: ~/.npm
    key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
    restore-keys: |
      ${{ runner.os }}-npm-
Enter fullscreen mode Exit fullscreen mode

✅ Log rotation:

sudo nano /etc/logrotate.d/myapp

/var/log/myapp.log /var/log/myapp.err {
  weekly
  rotate 4
  compress
  missingok
  notifempty
}

Enter fullscreen mode Exit fullscreen mode

🎯 Final Directory Layout on GCE

/opt/myapp/
├── artifact.zip
├── current/         ← active release
│   ├── src/
│   └── package.json
├── current_prev/    ← backup previous release
└── releases/
    └── <timestamp>/
Enter fullscreen mode Exit fullscreen mode

✅ Summary

Stage Action
Build npm ci using package-lock.json
Test Run Jest/unit tests
Artifact Create artifact.zip
Deploy Copy to GCE via SSH
Rollback Auto restore previous release if failed
Validate curl http://localhost:3000/health
Persistent Managed by systemd

🌟 Final Result

Your pipeline is now:

  • Secure (SSH key-based deploys)
  • Repeatable (lockfile ensures consistent builds)
  • Resilient (automatic rollback)
  • Continuous (Git push = deploy)
  • Observable (logs + health checks)

🌟 Thanks for reading! If this post added value, a like ❤️, follow, or share would encourage me to keep creating more content.


— Latchu | Senior DevOps & Cloud Engineer

☁️ AWS | GCP | ☸️ Kubernetes | 🔐 Security | ⚡ Automation
📌 Sharing hands-on guides, best practices & real-world cloud solutions

Top comments (0)