Skip to content

Automated CI/CD Practices for VitePress Projects with GitHub Actions ​

Diagram of automated CI/CD practices with GitHub Actions for a VitePress project

Preface ​

After completing Automated CI/CD Practice with GitHub Actions for Nuxt.js Projects After the series of articles, I received a very natural question:Can a VitePress documentation project also achieve automation in a similar way?

The answer is yes. This article is a complete practice of applying this set of CI/CD ideas to a VitePress documentation project.

Why is it worth writing a separate article?

Although the core ideas are similar, VitePress and Nuxt.js differ in the following aspects:

  • Build output directories differ.

  • Verify that the files are different

  • Different service management approaches

  • The package manager is different.

These differences are precisely what this article will focus on explaining.

Objective of this paper:

When you create a Release, automatically complete:

  • Code checkout → Dependency installation → Project build

  • Generate a compressed package and upload it to the Release page.

  • Automatically deploy to the server

  • Reload Nginx service

  • Email notification (success/failure)

NOTICE

This article assumes that you have already read the CI/CD practice articles in the Nuxt.js series, and focuses on explaining the differences. If you are not familiar with the basic concepts, it is recommended that you read the relevant documentation first.

Core Differences at a Glance ​

Comparison itemNuxt.js projectVitePress project
Build output directory.output/docs/.vitepress/dist/
Verification filenitro.jsonindex.html
Service ManagementPM2 restartNginx reload
Package managerpnpmnpm

Directory structure ​

.github/
└── workflows/
    ├── ci.yaml              # PR automated checks
    └── release-bump.yaml    # Build + Deploy + Notify

Complete configuration file ​

1. ci.yaml (PR automated checks) ​

yaml
name: ci

on:
  push:
    branches: [ master, docs/* ]
  pull_request:
    branches: [ master ]

jobs:
  check-links:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v6
        with:
          fetch-depth: 0

      - name: Setup Node.js
        uses: actions/setup-node@v5
        with:
          node-version: 20
          cache: npm

      - name: Install dependencies
        run: npm ci

      - name: Build VitePress
        env:
          NODE_OPTIONS: "--max-old-space-size=4096"
        run: npm run docs:build

2. release-bump.yaml (Build + Deploy + Notify) ​

yaml
name: Auto Bump Version on Release

on:
  release:
    types: [created]  # Trigger only when a new Release is created

jobs:
  # Job 1: Build and Release
  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:
      # 1. Check out the code
      - uses: actions/checkout@v6
        with:
          ref: ${{ github.event.release.target_commitish }}
          fetch-depth: 0

      # 2. Set up the Node.js environment
      - name: Setup Node.js
        uses: actions/setup-node@v5
        with:
          node-version: 20
          cache: npm

      # 3. Install dependencies (recommended: ci instead of install)
      - name: Install dependencies
        run: npm ci

      # 4. Extract version number
      - name: Extract Version
        id: extract-version
        run: |
          TAG=${GITHUB_REF#refs/tags/}
          VERSION=$(echo "$TAG" | sed 's/^v//')
          echo "version=$VERSION" >> $GITHUB_OUTPUT

      # 5. Extract the project name (dynamically retrieved from package.json)
      - name: Extract Project Name
        id: project-name
        run: |
          PROJECT_NAME=$(node -p "require('./package.json').name")
          echo "project_name=$PROJECT_NAME" >> $GITHUB_OUTPUT

      # 6. Update the package.json version number
      - name: Update Version
        run: |
          npm version ${{ steps.extract-version.outputs.version }} --no-git-tag-version
          cat package.json | grep version

      # 7. Build a VitePress project
      - name: Build VitePress
        env:
          NODE_OPTIONS: "--max-old-space-size=4096"
        run: npm run docs:build

      # 8. Create a compressed archive (using a dynamic project name)
      - name: Create Distribution Archive
        run: |
          if [ ! -d "docs/.vitepress/dist" ]; then
            echo "Error: ./docs/.vitepress/dist/ directory not found!"
            exit 1
          fi

          echo "Contents of ./docs/.vitepress/dist/ directory:"
          ls -la ./docs/.vitepress/dist/

          PROJECT_NAME="${{ steps.project-name.outputs.project_name }}"
          VERSION="${{ steps.extract-version.outputs.version }}"

          tar -czf ${PROJECT_NAME}-${VERSION}.tar.gz -C ./docs/.vitepress/dist/ .
          cd ./docs/.vitepress/dist && zip -r ../../../${PROJECT_NAME}-${VERSION}.zip . && cd ../../../

          echo "Archive created successfully!"
          ls -la *.tar.gz *.zip

      # 9. Upload to GitHub Release
      - name: Upload Release Assets
        uses: ncipollo/release-action@v1
        with:
          artifacts: "./${{ steps.project-name.outputs.project_name }}-${{ steps.extract-version.outputs.version }}.tar.gz,./${{ steps.project-name.outputs.project_name }}-${{ steps.extract-version.outputs.version }}.zip"
          allowUpdates: true
          omitBody: true
          omitName: true
          token: ${{ secrets.ACCESS_TOKEN }}
          makeLatest: "true"

      # 10. Upload build artifacts for use by subsequent Jobs
      - name: Upload Build Artifact
        uses: actions/upload-artifact@v7
        with:
          name: build-output
          path: ${{ steps.project-name.outputs.project_name }}-${{ steps.extract-version.outputs.version }}.tar.gz
          retention-days: 1
          if-no-files-found: error
          overwrite: true

      # 11. Commit version number change
      - name: Commit Changes
        run: |
          git config user.name "GitHub Actions"
          git config user.email "[email protected]"
          git add package.json package-lock.json
          git commit -m "chore(release): bump to ${{ steps.extract-version.outputs.version }} [skip ci]"
          git push origin HEAD:${{ github.event.release.target_commitish }}

      # 12. Build success email
      - name: Send build success email
        if: success()
        uses: dawidd6/action-send-mail@v3
        with:
          server_address: smtp.gmail.com
          server_port: 587
          username: ${{ vars.GMAIL_SMTP_USER }}
          password: ${{ secrets.GMAIL_SMTP_PASSWORD }}
          subject: ✅ VitePress build successful - ${{ github.repository }}
          to: ${{ vars.NOTIFY_EMAILS }}
          from: "GitHub Actions <${{ vars.GMAIL_SMTP_USER }}>"
          body: |
            🎉 VitePress project built successfully!

            Repository: ${{ github.repository }}
            Version: ${{ steps.extract-version.outputs.version }}
            Triggered by: ${{ github.actor }}
            Time: ${{ github.event.repository.updated_at }}
            Status: ✅ Success

            Details: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}

      # 13. Build failure email
      - name: Send build failure email
        if: failure()
        uses: dawidd6/action-send-mail@v3
        with:
          server_address: smtp.gmail.com
          server_port: 587
          username: ${{ vars.GMAIL_SMTP_USER }}
          password: ${{ secrets.GMAIL_SMTP_PASSWORD }}
          subject: "❌ VitePress Build Failed - ${{ github.repository }}"
          to: ${{ vars.NOTIFY_EMAILS }}
          from: "GitHub Actions <${{ vars.GMAIL_SMTP_USER }}>"
          body: |
            🚨 VitePress project build failed, please check immediately!

            Repository: ${{ github.repository }}
            Version: ${{ steps.extract-version.outputs.version }}
            Triggered by: ${{ github.actor }}
            Time: ${{ github.event.repository.updated_at }}
            Status: ❌ Failed

            Details: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
            Please click the link above to view detailed error information.

  # Job 2: Remote deployment
  remote-deploy:
    runs-on: ubuntu-latest
    needs: build-and-release
    if: github.event_name == 'release' && success()
    permissions:
      actions: read

    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..."
          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 (VitePress validates index.html)
      - name: Verify Extracted Content
        run: |
          if [ ! -f "index.html" ]; then
            echo "ERROR: index.html not found after extraction!"
            exit 1
          fi
          echo "✅ Artifact extracted successfully"

      # 4. SCP transfer to server
      - name: Deploy to Server
        uses: appleboy/[email protected]
        with:
          host: ${{ secrets.DEPLOY_HOST }}
          username: ${{ secrets.DEPLOY_USER }}
          key: ${{ secrets.DEPLOY_SSH_KEY }}
          port: ${{ vars.DEPLOY_PORT || 22 }}
          source: "./"
          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: ${{ vars.DEPLOY_PORT || 22 }}
          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}"

            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"
              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
            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. Reload the Nginx service (static site)
            echo "🔄 Reloading nginx service..."
            nginx -s reload

            # 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 "=========================================="
            systemctl status nginx

      # 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 "  HTML files: $(find . -maxdepth 1 -type f -name "*.html" | wc -l) files"
          echo "  Total directories (root): $(find . -maxdepth 1 -type d | wc -l) directories"
          echo ""
          echo "🔍 Key Files Check:"
          echo "  favicon.svg: $([ -f "favicon.svg" ] && echo '✅ YES' || echo '❌ NO')"
          echo "  favicon.ico: $([ -f "favicon.ico" ] && echo '✅ YES' || echo '❌ NO')"
          echo "  index.html: $([ -f "index.html" ] && echo '✅ YES' || echo '❌ NO')"
          echo "  404.html: $([ -f "404.html" ] && echo '✅ YES' || echo '❌ NO')"
          echo "  assets/: $([ -d "assets" ] && echo '✅ EXISTS' || echo '❌ NOT FOUND')"
          echo ""
          echo "⏱️  Time: $(date)"
          echo "=========================================="

      # 7. Deployment success email
      - name: Send deploy success email
        if: success()
        uses: dawidd6/action-send-mail@v3
        with:
          server_address: smtp.gmail.com
          server_port: 587
          username: ${{ vars.GMAIL_SMTP_USER }}
          password: ${{ secrets.GMAIL_SMTP_PASSWORD }}
          subject: ✅ VitePress Deployment Successful - ${{ github.repository }}
          to: ${{ vars.NOTIFY_EMAILS }}
          from: "GitHub Actions <${{ vars.GMAIL_SMTP_USER }}>"
          body: |
            🎉 VitePress project deployed successfully!

            Repository: ${{ github.repository }}
            Version: ${{ needs.build-and-release.outputs.version }}
            Triggered by: ${{ github.actor }}
            Time: ${{ github.event.repository.updated_at }}
            Status: ✅ Success

            🌐 Access address: ${{ vars.DEPLOY_WEBSITE_DOMAIN }}
            Details: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}

      # 8. Deployment failure email
      - name: Send deploy failure email
        if: failure()
        uses: dawidd6/action-send-mail@v3
        with:
          server_address: smtp.gmail.com
          server_port: 587
          username: ${{ vars.GMAIL_SMTP_USER }}
          password: ${{ secrets.GMAIL_SMTP_PASSWORD }}
          subject: "❌ VitePress deployment failed - ${{ github.repository }}"
          to: ${{ vars.NOTIFY_EMAILS }}
          from: "GitHub Actions <${{ vars.GMAIL_SMTP_USER }}>"
          body: |
            🚨 VitePress project deployment failed, please check immediately!

            Repository: ${{ github.repository }}
            Version: ${{ needs.build-and-release.outputs.version }}
            Triggered by: ${{ github.actor }}
            Time: ${{ github.event.repository.updated_at }}
            Status: ❌ Failed

            Details: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
            Please click the link above to view detailed error information.

Required Secrets and Variables configuration ​

In the GitHub repository's Settings -> Secrets and variables -> Actions Add to:

Secrets (sensitive information) ​

Secret NameDescription
ACCESS_TOKENGitHub Personal Access Token
GMAIL_SMTP_PASSWORDGmail SMTP app-specific password
DEPLOY_HOSTServer IP or domain name
DEPLOY_USERSSH login username
DEPLOY_SSH_KEYSSH private key content

Variables (non-sensitive configuration) ​

Variable nameExplanationExample
GMAIL_SMTP_USERGmail email address[email protected]
NOTIFY_EMAILSNotification email list[email protected],[email protected]
DEPLOY_WEBSITE_DOMAINWebsite access addresshttps://docs.example.com
DEPLOY_PORTSSH port (optional)22

Detailed Explanation of Key Differences ​

1. Build output path ​

ProjectPath
Nuxt.js.output/
VitePressdocs/.vitepress/dist/
bash
# VitePress build command
npm run docs:build

# Verification after packaging
if [ ! -d "docs/.vitepress/dist" ]; then
  echo "Error: ./docs/.vitepress/dist/ directory not found!"
  exit 1
fi

2. Verification file ​

ProjectVerification fileDescription
Nuxt.jsnitro.jsonNuxt build artifact logo
VitePressindex.htmlStatic site entry file
bash
# VitePress validation logic
if [ ! -f "index.html" ]; then
  echo "ERROR: index.html not found after extraction!"
  exit 1
fi

3. Service Management Method ​

ProjectService ManagementExplanation
Nuxt.jspm2 restartNode.js 服务进程管理
VitePressnginx -s reloadStatic file service reload
bash
# After deploying VitePress
nginx -s reload
systemctl status nginx

4. Package Manager ​

Projectpackage managerInstallation command
Nuxt.jspnpmpnpm install --frozen-lockfile
VitePressnpmnpm ci

💡npm ci compare npm install More suitable for CI environments; it strictly follows package-lock.json Installation is faster and more stable.

5. Archive creation path ​

bash
# VitePress build (pay attention to path hierarchy)
tar -czf ${PROJECT_NAME}-${VERSION}.tar.gz -C ./docs/.vitepress/dist/ .
cd ./docs/.vitepress/dist && zip -r ../../../${PROJECT_NAME}-${VERSION}.zip . && cd ../../../

Operating process ​

PR workflow (ci.yaml) ​

  • PR create/update → trigger workflow

  • Checkout code → Install dependencies → Build verification

  • Ensure the PR does not break the documentation build.

Release process (release-bump.yaml) ​

  • Create Release → Trigger workflow

  • Checkout code → Install dependencies → Build VitePress

  • Packaging docs/.vitepress/dist/ Content

  • Upload to GitHub Release

  • Upload artifact

  • Send build notification email

  • Download artifact → Extract → Verify index.html

  • SCP transfer to the server

  • Back up the current version → Deploy the new files

  • Nginx reload

  • Send deployment notification email (including access URL)

FAQ ​

Q: Why is the verification file from nitro.json became index.html? ​

A: The VitePress build output consists of pure static files, and the entry file is index.html, whereas Nuxt.js outputs a Nitro server application, and the marker file is nitro.json.

Q: Why did service management change from PM2 to Nginx? ​

A: VitePress generates static files, so no Node.js process is required to host them; you only need Nginx to provide static file serving.

Q: Why use npm ci rather than npm install? ​

A:npm ci Designed specifically for CI environments, strictly following package-lock.json, faster and more reliable installation.

Q: How do I configure Nginx to host VitePress static files? ​

A: Refer to the following configuration (including HTTPS support and SPA route handling):

nginx
# HTTPS configuration (port 443)
server {
    server_name fishfinal.com;

    # Proxy the VitePress project build directory
    location / {
        root   /var/www/fishfinal;
        index  index.html;
        # Fix the 404 issue after page refresh (SPA routing)
        try_files $uri $uri/ @router;
    }

    # Route rewriting after page refresh
    location @router {
        rewrite ^.*$ /index.html last;
    }

    listen [::]:443 ssl;
    listen 443 ssl;
    ssl_certificate /etc/letsencrypt/live/fishfinal.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/fishfinal.com/privkey.pem;
    include /etc/letsencrypt/options-ssl-nginx.conf;
    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
}

# HTTP redirect to HTTPS (port 80)
server {
    if ($host = fishfinal.com) {
        return 301 https://$host$request_uri;
    }

    listen 80;
    listen [::]:80;
    server_name fishfinal.com;
    return 404;
}

Key configuration notes:

Configuration itemDescription
root /var/www/fishfinalVitePress build output directory
try_files $uri $uri/ @routerFirst try to match static files; if that fails, proceed to route rewriting.
location @routerRewrite unmatched requests to index.html
rewrite ^.*$ /index.html lastImplement SPA frontend routing to solve 404 on page refresh
SSL Certificate ConfigurationLet's Encrypt automatic management

💡 VitePress is a static site + SPA routing, and must be configured try_files+@router Only then can it support page refreshes without a 404.

Summary ​

This article is based on the CI/CD concepts of the Nuxt.js series and has successfully migrated them to a VitePress documentation project. The main adjustments include:

  • ✅ Build output path:docs/.vitepress/dist/

  • ✅ Verification file:index.html

  • ✅ Service Management:nginx -s reload

  • ✅ Package manager:npm ci

The core idea remains unchanged, while details are adjusted as needed—this is exactly the essence of CI/CD automation.

The approach in this article is derived from the following series of articles. It is recommended to read them first to understand the complete CI/CD design philosophy:

💡 Confirmation Point

After running this workflow, the Release page should have tar.gz and zip Attachment: the server directory should be updated to the latest version, the Nginx service status is normal, and a notification has been received by email.

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