Vue deployment caught me off guard the first time. I expected it to be complicated. It's actually pretty simple: run npm run build, you get a dist folder, and Nginx serves those files. No Node.js process needed in production.
The one thing that trips people up — and it got me too — is the publicPath setting when the app isn't served from the root URL, and client-side routing when users navigate directly to a deep URL. If history mode is on in Vue Router and Nginx doesn't know about it, you'll get 404 errors on page refresh.
Both issues have clean solutions. This guide covers the full deployment: Vite build, Nginx config with the routing fix, HTTPS, and automated deploys via GitHub Actions.uting support, and automated GitHub Actions deployments.
I run this on Tencent Cloud Lighthouse. A Vue SPA with Nginx needs minimal server resources — even the entry-level plan handles significant traffic since Nginx efficiently serves static files with very low overhead. For projects where the Vue frontend connects to a backend API on the same server, Lighthouse's single fixed monthly pricing covers both the frontend and backend — no additional infrastructure cost. If your app serves an international audience, Tencent Cloud EdgeOne can sit in front to deliver assets from edge nodes closer to your users.
Key Takeaways
- Vue production build is static files — Nginx serves them directly, no Node.js at runtime
- Add
try_files $uri $uri/ /index.html;to fix Vue Router 404 on direct page accessVITE_prefix is required for environment variables to be included in the build bundle- Set
base: '/subpath/'invite.config.jsif not served from domain root- GitHub Actions automates build and deployment on every push to main
Frequently Asked Questions {#faq}
Does Vue.js need Node.js running in production?
Not for a standard Vite or Vue CLI production build. npm run build creates static files that Nginx serves directly. Node.js is only needed during the build process, not at runtime.
How do I fix Vue Router navigation errors returning 404?
Add try_files $uri $uri/ /index.html; to your Nginx location block. This routes all non-file requests to index.html, letting Vue Router handle navigation on the client side.
What is the publicPath / base setting in Vue and when do I need it?
If your app isn't served from the root URL (e.g., domain.com/app/), set base: '/app/' in vite.config.js. Without this, asset URLs will be broken.
How do I handle API calls in Vue when the frontend and backend are on different servers?
Configure a proxy in vite.config.js for development. For production, either serve the API from the same domain using Nginx location /api/ proxy rules, or configure CORS on your backend API server.
How do environment variables work in Vue/Vite?
Create .env.production with variables prefixed VITE_. They're baked into the build bundle at compile time. Access them as import.meta.env.VITE_API_URL in your code.
Local development Production server
npm run dev → npm run build → /dist
(dev server) (static files) → Nginx serves /dist
The npm run build command (via Vite or Vue CLI) compiles your Vue components, optimizes assets, and outputs static files. Nginx serves these files — there's no Node.js runtime needed in production.
Vue Router in HTML5 History mode requires one server configuration step: all routes must fall back to index.html so Vue Router can handle them client-side.
| Requirement | Notes |
|---|---|
| Node.js locally | For building the app |
| Cloud server | Tencent Cloud Lighthouse Ubuntu 22.04 |
| Nginx on server | sudo apt install nginx |
| Vue 3 project | Vite-based (created with npm create vue@latest) |
On your local machine:
# Install dependencies
npm install
# Build for production
npm run build
# Output is in the /dist directory
ls dist/
# index.html assets/ favicon.ico
Vite automatically:
ssh ubuntu@YOUR_SERVER_IP
sudo apt update && sudo apt upgrade -y
sudo apt install -y nginx
sudo ufw allow ssh
sudo ufw allow 'Nginx Full'
sudo ufw enable
# Create web root
sudo mkdir -p /var/www/myvueapp
sudo chown ubuntu:www-data /var/www/myvueapp
sudo chmod 755 /var/www/myvueapp
# On your local machine
scp -r dist/* ubuntu@YOUR_SERVER_IP:/var/www/myvueapp/
rsync -avz --delete dist/ ubuntu@YOUR_SERVER_IP:/var/www/myvueapp/
# --delete removes old files that no longer exist
Verify the upload:
# On the server
ls /var/www/myvueapp/
# index.html assets/
sudo nano /etc/nginx/sites-available/myvueapp
server {
listen 80;
server_name yourdomain.com www.yourdomain.com;
root /var/www/myvueapp;
index index.html;
access_log /var/log/nginx/myvueapp_access.log;
error_log /var/log/nginx/myvueapp_error.log;
# All routes fall back to index.html (required for Vue Router)
location / {
try_files $uri $uri/ /index.html;
}
# Cache hashed assets aggressively (Vite adds hashes to filenames)
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Gzip compression
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml;
gzip_vary on;
}
sudo ln -s /etc/nginx/sites-available/myvueapp /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
Visit http://YOUR_SERVER_IP — your Vue app should load.
If you use Vue Router with createWebHistory() (HTML5 History mode), you need the try_files fallback. This is already included in the Nginx config above.
// router/index.js
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{ path: '/', component: HomeView },
{ path: '/about', component: AboutView },
{ path: '/user/:id', component: UserView },
]
})
With try_files $uri $uri/ /index.html:
/ → serves index.html → Vue Router handles it/about → no file found → falls back to index.html → Vue Router handles /about/assets/main.js → actual file exists → served directlyIf you use createWebHashHistory() instead (hash-based routing like /#/about), you don't need the fallback — but hash URLs are less clean and not recommended for new projects.
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d yourdomain.com -d www.yourdomain.com
Create .github/workflows/deploy.yml in your Vue project:
name: Deploy Vue App
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install and build
run: npm ci && npm run build
env:
VITE_API_URL: ${{ secrets.VITE_API_URL }}
- name: Deploy to server
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ secrets.SERVER_IP }}
username: ubuntu
key: ${{ secrets.SSH_PRIVATE_KEY }}
source: "dist/"
target: "/var/www/myvueapp"
strip_components: 1
Add to GitHub repo secrets:
SERVER_IP — your server's public IPSSH_PRIVATE_KEY — your private key for SSH accessVITE_API_URL — your API URL (if needed)Vue (Vite) environment variables are embedded at build time:
# .env.production (used during `npm run build`)
VITE_API_URL=https://api.yourdomain.com
VITE_APP_TITLE=My Vue App
Access in your Vue code:
const apiUrl = import.meta.env.VITE_API_URL
const title = import.meta.env.VITE_APP_TITLE
Important rules:
VITE_ are exposed in the browsernpm run build after changing .env.production for changes to take effectenv: in the build stepIf you deploy your Vue app to a subdirectory (e.g., yourdomain.com/app/ instead of the root), the asset paths in the built files will be wrong — they'll point to /assets/... instead of /app/assets/....
Fix in vite.config.js:
export default defineConfig({
base: '/app/', // Set to your subdirectory path
// ...
})
And update Nginx to serve from that path:
location /app/ {
root /var/www/myvueapp;
index index.html;
try_files $uri $uri/ /app/index.html;
}
For root deployments (the most common case), leave base as the default ('/') and don't change the Nginx config.
| Issue | Likely Cause | Fix |
|---|---|---|
| Connection refused | Service not running or wrong port | Check systemctl status SERVICE and verify firewall rules |
| Permission denied | Wrong file ownership or permissions | Check file ownership with ls -la and use chown/chmod to fix |
| 502 Bad Gateway | Backend service not running | Restart the backend service; check logs with journalctl -u SERVICE |
| SSL certificate error | Certificate expired or domain mismatch | Run sudo certbot renew and verify domain DNS points to server IP |
| Service not starting | Config error or missing dependency | Check logs with journalctl -u SERVICE -n 50 for specific error |
| Out of disk space | Logs or data accumulation | Run df -h to identify usage; clean logs or attach CBS storage |
| High memory usage | Too many processes or memory leak | Check with htop; consider upgrading instance plan if consistently high |
| Firewall blocking traffic | Port not open in UFW or Lighthouse console | Open port in Lighthouse console firewall AND sudo ufw allow PORT |
Deploy your Vue app today:
👉 Tencent Cloud Lighthouse — Fast Ubuntu VPS for Vue apps
👉 View current pricing and promotions
👉 Explore all active deals and offers