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

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 item | Nuxt.js project | VitePress project |
|---|---|---|
| Build output directory | .output/ | docs/.vitepress/dist/ |
| Verification file | nitro.json | index.html |
| Service Management | PM2 restart | Nginx reload |
| Package manager | pnpm | npm |
Directory structure
.github/
└── workflows/
├── ci.yaml # PR automated checks
└── release-bump.yaml # Build + Deploy + NotifyComplete 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:build2. 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 Name | Description |
|---|---|
ACCESS_TOKEN | GitHub Personal Access Token |
GMAIL_SMTP_PASSWORD | Gmail SMTP app-specific password |
DEPLOY_HOST | Server IP or domain name |
DEPLOY_USER | SSH login username |
DEPLOY_SSH_KEY | SSH private key content |
Variables (non-sensitive configuration)
| Variable name | Explanation | Example |
|---|---|---|
GMAIL_SMTP_USER | Gmail email address | [email protected] |
NOTIFY_EMAILS | Notification email list | [email protected],[email protected] |
DEPLOY_WEBSITE_DOMAIN | Website access address | https://docs.example.com |
DEPLOY_PORT | SSH port (optional) | 22 |
Detailed Explanation of Key Differences
1. Build output path
| Project | Path |
|---|---|
| Nuxt.js | .output/ |
| VitePress | docs/.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
fi2. Verification file
| Project | Verification file | Description |
|---|---|---|
| Nuxt.js | nitro.json | Nuxt build artifact logo |
| VitePress | index.html | Static site entry file |
bash
# VitePress validation logic
if [ ! -f "index.html" ]; then
echo "ERROR: index.html not found after extraction!"
exit 1
fi3. Service Management Method
| Project | Service Management | Explanation |
|---|---|---|
| Nuxt.js | pm2 restart | Node.js 服务进程管理 |
| VitePress | nginx -s reload | Static file service reload |
bash
# After deploying VitePress
nginx -s reload
systemctl status nginx4. Package Manager
| Project | package manager | Installation command |
|---|---|---|
| Nuxt.js | pnpm | pnpm install --frozen-lockfile |
| VitePress | npm | npm 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/ContentUpload to GitHub Release
Upload artifact
Send build notification email
Download artifact → Extract → Verify
index.htmlSCP 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 item | Description |
|---|---|
root /var/www/fishfinal | VitePress build output directory |
try_files $uri $uri/ @router | First try to match static files; if that fails, proceed to route rewriting. |
location @router | Rewrite unmatched requests to index.html |
rewrite ^.*$ /index.html last | Implement SPA frontend routing to solve 404 on page refresh |
| SSL Certificate Configuration | Let'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.
Related documents
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:
Part One: Build and Release- Automatically build, package, and upload Release
Part Two: Remote Deployment- SCP transfer, SSH deployment, PM2 restart
Part Three: Email Notification- Real-time awareness of build/deployment status
💡 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.