Skip to content

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

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

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:

PartGoalStatus
Part OneBuild, package, and upload to GitHub Release✅ Completed
Part Two (Main Text)自动部署到远程服务器,PM2 重启服务📍 Current
Part ThreeCI/CD email notificationPending 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 job

Complete 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 nameDescriptionExample
DEPLOY_HOSTServer IP or domain name192.168.1.100 or example.com
DEPLOY_USERSSH login usernameroot or ubuntu
DEPLOY_SSH_KEYSSH private key content-----BEGIN OPENSSH PRIVATE KEY-----
DEPLOY_PORTSSH 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 artifacts
  • needs: build-and-release: Ensure deployment is executed only after the build Job succeeds

  • if: github.event_name == 'release' && success(): Only execute when triggered by Release and the build succeeds

  • permissions: actions: read Allow 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 .output packaging layer

  • Verification nitro.json Does 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:

StepcommandDescription
Backupcp -r "$DEPLOY_DIR" "$BACKUP_DIR"Save the current version for easy rollback.
Deploymentcp -r ${BUILD_DIR}/* ${DEPLOY_DIR}/Copy the new file to the production directory (Note: there is no .output layer)
Restartpm2 restart ${PROJECT_NAME}Restart the PM2 service
Clean uprm -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:

StageDirectory structureExplanation
Before packaging.output/ → nitro.json,public/,server/Build artifacts
After packaging${PROJECT_NAME}-${VERSION}.tar.gzThe archive contains the files directly.
After decompressionCurrent directory →nitro.json,public/,server/No .output Layer
SCP transfersource: "./"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 pm2

  • Service 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 save

Reminder

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 name

Summary ​

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.

Series navigation ​

Last updated on11/07/2026, 02:16 PM
@fishfinal | @fishfinals | [email protected]