English
Nuxt.js Project GitHub Actions Automated CI/CD Practice (Part 2): Remote Deployment

Preface
承接第一篇文章,我们已经实现了创建 Release 时自动构建并打包。本文将讲解How to automatically deploy build artifacts to a remote server, achieving full automation from code commit to service deployment.
Review of this series of articles:
| Part | Goal | Status |
|---|---|---|
| Part One | Build, package, and upload to GitHub Release | ✅ Completed |
| Part Two (Main Text) | 自动部署到远程服务器,PM2 重启服务 | 📍 Current |
| Part Three | CI/CD email notification | Pending publication |
Achieved effect:
After you create a Release, the first part automatically completes the build and packaging, and the second part will automatically:
Download build artifacts
Extract and verify file integrity
SCP transfer to the target server
Back up the current version
Deploy new file
PM2 restart service
Clean up temporary files
The entire process requires no manual intervention, truly achieving One-click publishing, automatic deployment.
NOTICE
This article is Part Two of the series, focusing on the remote deployment phase. It is recommended to complete the configuration in Part 1 first before continuing with this article.
Part II Objectives
After the first part is built, automatically complete:
Download build artifacts (artifact)
Extract and verify file integrity
SCP transfer to the target server
Back up the currently running version
Deploy new files to the production directory
PM2 restart service
Clean up temporary files
Directory structure (continued from Part 1)
.github/
└── workflows/
└── release-bump.yaml # Add remote-deploy jobComplete configuration file
In the first part's release-bump.yaml On the basis of, add remote-deploy Job:
yaml
name: Auto Bump Version on Release
on:
release:
types: [created]
jobs:
# Job 1: Build and release (see Part 1 for details)
build-and-release:
runs-on: ubuntu-latest
permissions:
contents: write
outputs:
version: ${{ steps.extract-version.outputs.version }}
project_name: ${{ steps.project-name.outputs.project_name }}
steps:
# ... all the steps in the first part ...
# Includes: checkout, install dependencies, build, package, upload Release, upload artifact
# Job 2: Remote deployment
remote-deploy:
runs-on: ubuntu-latest
needs: build-and-release # Waiting for the build to complete
if: github.event_name == 'release' && success()
permissions:
actions: read # Allow downloading artifacts
steps:
# 1. Download build artifacts
- name: Download Artifact
uses: actions/download-artifact@v8
with:
name: build-output
path: ./
# 2. Extract artifact
- name: Extract Artifact
run: |
echo "Contents before extract:"
ls -la
echo ""
echo "Extracting tar.gz..."
# Extract directly into the current directory, including the nitro.json file and the public and server directories.
tar -xzf ${{ needs.build-and-release.outputs.project_name }}-${{ needs.build-and-release.outputs.version }}.tar.gz
echo "Extracted contents:"
ls -la
echo "Total files: $(find . -maxdepth 1 -type f | wc -l)"
# 3. Verify the extracted content
- name: Verify Extracted Content
run: |
if [ ! -f "nitro.json" ]; then
echo "ERROR: nitro.json not found after extraction!"
exit 1
fi
echo "✅ Artifact extracted successfully"
# 4. SCP transfer to server (transfer all files in the current directory)
- name: Deploy to Server
uses: appleboy/[email protected]
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
port: ${{ secrets.DEPLOY_PORT || 22 }}
source: "./" # Transfer all contents in the current directory
target: "/tmp/${{ needs.build-and-release.outputs.project_name }}-build-${{ needs.build-and-release.outputs.version }}/"
# 5. SSH Execute Remote Deployment
- name: Execute Remote Deployment
uses: appleboy/[email protected]
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
port: ${{ secrets.DEPLOY_PORT || 22 }}
script: |
set -e # Exit immediately on error
PROJECT_NAME="${{ needs.build-and-release.outputs.project_name }}"
VERSION="${{ needs.build-and-release.outputs.version }}"
DEPLOY_DIR="/var/www/${PROJECT_NAME}"
BUILD_DIR="/tmp/${PROJECT_NAME}-build-${VERSION}"
echo "=========================================="
echo "Deploying ${PROJECT_NAME} version ${VERSION}"
echo "=========================================="
# 1. Back up the current version
if [ -d "$DEPLOY_DIR" ]; then
BACKUP_DIR="/var/www/${PROJECT_NAME}-backup-$(date +%Y%m%d_%H%M%S)"
echo "📦 Creating backup: ${BACKUP_DIR}"
cp -r "$DEPLOY_DIR" "$BACKUP_DIR"
# Keep the most recent 5 backups
ls -t /var/www/${PROJECT_NAME}-backup-* 2>/dev/null | tail -n +6 | xargs rm -rf || true
else
echo "📁 Creating deployment directory: ${DEPLOY_DIR}"
mkdir -p "$DEPLOY_DIR"
fi
# 2. Deploy new files
# Note: After extraction, the files are directly under BUILD_DIR, with no .output layer.
echo "📤 Deploying files..."
rm -rf ${DEPLOY_DIR}/*
cp -r ${BUILD_DIR}/* ${DEPLOY_DIR}/
# 3. Set permissions
chown -R www-data:www-data ${DEPLOY_DIR}
chmod -R 755 ${DEPLOY_DIR}
# 4. Restart the PM2 service
echo "🔄 Restarting PM2 service..."
pm2 restart ${PROJECT_NAME}
# 5. Clean up temporary files
echo "🧹 Cleaning up..."
rm -rf ${BUILD_DIR}
# 6. Display deployment results
echo "=========================================="
echo "✅ Deployment completed successfully!"
echo "📍 Deployed to: ${DEPLOY_DIR}"
echo "📌 Version: ${VERSION}"
echo "=========================================="
pm2 status ${PROJECT_NAME}
# 6. Output Deployment Overview
- name: Build Summary
run: |
echo "=========================================="
echo "🎉 DEPLOYMENT SUMMARY"
echo "=========================================="
echo "Project: ${{ needs.build-and-release.outputs.project_name }}"
echo "Version: ${{ needs.build-and-release.outputs.version }}"
echo "Status: ${{ job.status }}"
echo ""
echo "📊 Directory Statistics:"
echo " JSON files (root): $(find . -maxdepth 1 -type f -name "*.json" | wc -l) files"
echo " Total directories (root): $(find . -maxdepth 1 -type d | wc -l) directories"
echo ""
echo "🔍 Key Files Check:"
echo " nitro.json: $([ -f "nitro.json" ] && echo '✅ YES' || echo '❌ NO')"
echo " public/: $([ -d "public" ] && echo '✅ EXISTS' || echo '❌ NOT FOUND')"
echo " server/: $([ -d "server" ] && echo '✅ EXISTS' || echo '❌ NOT FOUND')"
echo ""
echo "⏱️ Time: $(date)"
echo "=========================================="Required Secrets configuration
In the first part's ACCESS_TOKEN On this basis, add the following Secrets:
In the GitHub repository's Settings -> Secrets and variables -> Actions Add to:
| Secret name | Description | Example |
|---|---|---|
DEPLOY_HOST | Server IP or domain name | 192.168.1.100 or example.com |
DEPLOY_USER | SSH login username | root or ubuntu |
DEPLOY_SSH_KEY | SSH private key content | -----BEGIN OPENSSH PRIVATE KEY----- |
DEPLOY_PORT | SSH port (optional) | 22 |
How to generate an SSH key pair
bash
# Generate an SSH key pair locally (if you don't already have one)
ssh-keygen -t ed25519 -C "github-actions-deploy" -f ~/.ssh/github_deploy_key
# Add the public key to the server's authorized_keys
ssh-copy-id -i ~/.ssh/github_deploy_key.pub user@your-server
# Copy the private key contents to the GitHub Secret
cat ~/.ssh/github_deploy_key💡 Note
Private key content is required.Copy in full, including
-----BEGIN OPENSSH PRIVATE KEY-----and-----END OPENSSH PRIVATE KEY-----Ensure the server user has permission to write to the deployment directory and restart the PM2 service.
Detailed Explanation of Key Steps
1. Job Dependencies and Permissions
yaml
remote-deploy:
runs-on: ubuntu-latest
needs: build-and-release # Waiting for the build to complete
if: github.event_name == 'release' && success()
permissions:
actions: read # Allow downloading artifactsneeds: build-and-release: Ensure deployment is executed only after the build Job succeedsif: github.event_name == 'release' && success(): Only execute when triggered by Release and the build succeedspermissions: actions: readAllow downloading artifact (required)
2. Download build artifacts
yaml
- name: Download Artifact
uses: actions/download-artifact@v8
with:
name: build-output
path: ./Download the tar.gz file from the artifact uploaded in Part 1. Use path: ./ Indicates downloading to the current working directory.
3. Decompression and Verification
yaml
- name: Extract Artifact
run: |
echo "Contents before extract:"
ls -la
echo ""
echo "Extracting tar.gz..."
# Extract directly to the current directory without creating an .output wrapper layer.
tar -xzf ${{ needs.build-and-release.outputs.project_name }}-${{ needs.build-and-release.outputs.version }}.tar.gz
echo "Extracted contents:"
ls -la
echo "Total files: $(find . -maxdepth 1 -type f | wc -l)"
- name: Verify Extracted Content
run: |
if [ ! -f "nitro.json" ]; then
echo "ERROR: nitro.json not found after extraction!"
exit 1
fi
echo "✅ Artifact extracted successfully"Directly extract the tar.gz file to the current directory, without creating extra
.outputpackaging layerVerification
nitro.jsonDoes it exist (the marker file of the Nuxt build output)
4. SCP Transfer
yaml
- name: Deploy to Server
uses: appleboy/[email protected]
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
port: ${{ secrets.DEPLOY_PORT || 22 }}
source: "./" # Transfer all contents in the current directory
target: "/tmp/${{ needs.build-and-release.outputs.project_name }}-build-${{ needs.build-and-release.outputs.version }}/"Use appleboy/scp-action Transfer all files in the current directory to the server's temporary directory.
💡Path description: Transferred to /tmp/项目名-build-版本号/ Temporary directory to avoid directly overwriting the online directory. The deployment script will copy files from it to the formal directory.
5. SSH Remote Deployment
yaml
- name: Execute Remote Deployment
uses: appleboy/[email protected]
with:
script: |
set -e
PROJECT_NAME="${{ needs.build-and-release.outputs.project_name }}"
VERSION="${{ needs.build-and-release.outputs.version }}"
DEPLOY_DIR="/var/www/${PROJECT_NAME}"
BUILD_DIR="/tmp/${PROJECT_NAME}-build-${VERSION}"
# Backup
if [ -d "$DEPLOY_DIR" ]; then
BACKUP_DIR="/var/www/${PROJECT_NAME}-backup-$(date +%Y%m%d_%H%M%S)"
cp -r "$DEPLOY_DIR" "$BACKUP_DIR"
fi
# Deployment (Note: after extraction, the files are directly under BUILD_DIR, with no .output layer)
rm -rf ${DEPLOY_DIR}/*
cp -r ${BUILD_DIR}/* ${DEPLOY_DIR}/
# Set permissions
chown -R www-data:www-data ${DEPLOY_DIR}
chmod -R 755 ${DEPLOY_DIR}
# Restart PM2
pm2 restart ${PROJECT_NAME}
# Clean
rm -rf ${BUILD_DIR}Script description:
| Step | command | Description |
|---|---|---|
| Backup | cp -r "$DEPLOY_DIR" "$BACKUP_DIR" | Save the current version for easy rollback. |
| Deployment | cp -r ${BUILD_DIR}/* ${DEPLOY_DIR}/ | Copy the new file to the production directory (Note: there is no .output layer) |
| Restart | pm2 restart ${PROJECT_NAME} | Restart the PM2 service |
| Clean up | rm -rf ${BUILD_DIR} | Delete temporary files |
⚠️Key details: Because during extraction it was extracted directly into the current directory (without creating .output layer), SCP transfer source: "./" Afterwards, on the server BUILD_DIR directly under it is the file. Therefore, when copying, you should use ${BUILD_DIR}/* rather than ${BUILD_DIR}/.output/*.
Comparison of directory structures for packaging and unpacking:
| Stage | Directory structure | Explanation |
|---|---|---|
| Before packaging | .output/ → nitro.json,public/,server/ | Build artifacts |
| After packaging | ${PROJECT_NAME}-${VERSION}.tar.gz | The archive contains the files directly. |
| After decompression | Current directory →nitro.json,public/,server/ | No .output Layer |
| SCP transfer | source: "./" | Transfer all files in the current directory. |
| on the server | ${BUILD_DIR}/→nitro.json,public/,server/ | The file is directly in the root directory. |
| After deployment | ${DEPLOY_DIR}/→nitro.json,public/,server/ | The file is directly in the root directory. |
💡Permission Description:chown Set the file owner to www-data, ensure that Nginx has permission to read static files;chmod 755 Set the directory permissions so that the owner can read, write, and execute, and group users and other users can read and execute.
6. PM2 Service Management
bash
pm2 restart ${PROJECT_NAME}Prerequisite:
PM2 is already installed on the server:
npm install -g pm2Service registered:
pm2 start /var/www/项目名/server/index.mjs --name 项目名PM2 auto-start on boot:
pm2 save && pm2 startup
Operation process
Create a new Release in the GitHub repository (e.g.
v0.4.0-alpha15)Part One Auto-trigger: Build → Package → Upload Release → Upload artifact
Part Two Auto trigger (waiting for the first part to complete):
Download artifact → Extract → Verify
SCP transfer to the server temporary directory
SSH execute backup → deploy → PM2 restart → cleanup
Deployment complete, service online.
Frequently Asked Questions
Q:download-artifact Error "Artifact not found"?
A: Ensure that the first part correctly uploaded the artifact, and name: build-output Consistent with when uploaded.
Q: SCP transfer failed with "Permission denied"?
A: Check the following points:
Is the SSH private key correctly configured?
Does the server user have permission to write to the target directory?
Whether the target directory exists (you can log in via SSH and create it manually first)
Q: PM2 restart failed, prompting "Process not found"?
A: For the first deployment, you need to start the service first:
bash
# Log in to the server via SSH and execute it manually once.
pm2 start /var/www/project-name/server/index.mjs --name project-name --node-args="--env-file=.env"
pm2 saveReminder
For the complete deployment process of a Nuxt.js project, you can refer to Deploying a Nuxt Application to an Ubuntu 22.04 Server from Scratch This blog post!
Q: After deployment, the file path is incorrect, and the service cannot start?
A: Check the copy path in the deployment script. Because when extracting, it extracts directly to the current directory (without creating .output layer), SCP transfer source: "./" Afterwards, on the server BUILD_DIR Directly below is the file. Should use:
bash
cp -r ${BUILD_DIR}/* ${DEPLOY_DIR}/rather than:
bash
cp -r ${BUILD_DIR}/.output/* ${DEPLOY_DIR}/Q: How do I roll back to the previous version?
A: A backup directory is retained on the server:
bash
# View backup
ls -la /var/www/project-name-backup-*
# Restore backup
rm -rf /var/www/project-name
cp -r /var/www/project-name-backup-20250101_120000 /var/www/project-name
pm2 restart project nameSummary
This article completes the second part of the series—remote deployment automation. By configuring GitHub Actions, we have achieved:
Automatically download and extract build artifacts
SCP transfer to the target server
SSH execute remote deployment command
Automatically back up the current version
Restart services with PM2 to achieve seamless deployment.
At this point, the complete CI/CD pipeline has been fully connected:
Create Release → Auto Build → Auto Package → Auto Upload → Auto Deploy → PM2 Restart → Go-Live Complete💡 Confirmation Point
After running this workflow, the server's /var/www/项目名/ The directory should be updated to the latest version, and the PM2 service status is online.