🧩 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.
- Every time you push code to the main branch:
- GitHub Actions builds & tests your code
- Creates a zip artifact
- Securely copies it to your GCE VM
- Deploys and restarts the app using systemd
- 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
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
Add package.json scripts:
npm install
npm set-script start "node src/index.js"
npm set-script test "echo 'No tests yet'"
Then create a package-lock.json:
npm install
✅ Commit both files — package.json and package-lock.json.
You can use below source code repository
https://github.com/kohlidevops/github-action-project1
☁️ Step 2 — Create a Google Cloud VM
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
Copy the public key (~/.ssh/gcpdeploy_key.pub) into the VM → Edit → SSH Keys.
Note the external IP.
🧰 Step 3 — Prepare Your VM
SSH into your VM:
ssh -i ~/.ssh/gcpdeploy_key ubuntu@<your-external-ip>
Install dependencies:
curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -
sudo apt install -y nodejs unzip
Create app directory:
sudo mkdir -p /opt/myapp
sudo chown -R ubuntu:ubuntu /opt/myapp
⚙️ 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
Enable service:
sudo systemctl daemon-reload
sudo systemctl enable myapp
🔐 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 |
⚙️ 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"
🧾 Step 7 — Trigger a Deployment
Commit and push:
git add .
git commit -m "Enable full CI/CD deployment"
git push origin main
GitHub Actions will:
- Checkout & build
- Install deps from package-lock.json
- Run tests
- Zip app
- Upload via SCP
- Unzip on GCE
- Restart myapp.service
- Validate health
🧩 Step 8 — Verify on VM
Check service:
sudo systemctl status myapp
View logs:
tail -f /var/log/myapp.log
App should be live at:
http://<your-external-ip>:3000
🧠 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-
✅ Log rotation:
sudo nano /etc/logrotate.d/myapp
/var/log/myapp.log /var/log/myapp.err {
weekly
rotate 4
compress
missingok
notifempty
}
🎯 Final Directory Layout on GCE
/opt/myapp/
├── artifact.zip
├── current/ ← active release
│ ├── src/
│ └── package.json
├── current_prev/ ← backup previous release
└── releases/
└── <timestamp>/
✅ 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)