Vue.js course · Module 12: Testing & Deploy
Deployment
In this lesson7
Deploying the application to a server - rocket launch! Your code flies to production like a rocket to Mars!
The dist folder from the previous lesson sits on your disk. It has to be carried into orbit: onto a server that serves the files to every technician at a permanent address and over HTTPS - and without manual copying for every fix. The answer is CI/CD, meaning Continuous Integration and Continuous Deployment - the automation of tests, builds and deployments that runs the whole launch procedure by itself after every code push.
Deployment on Vercel
The simplest way is to connect a Git repository in the Vercel dashboard. Vercel detects the Vite project on its own and sets the npm run build command and the dist directory, then deploys production after every push to the main branch and prepares a preview address for every pull request. If you prefer the terminal, use the Vercel CLI:
1# Install Vercel CLI
2npm install -g vercel
3
4# Deploy
5vercel
6
7# Production deployment
8vercel --prodA plain vercel is short for vercel deploy, because deploy is the CLI's default command. Without a flag it creates a preview deployment at a unique address, and --prod publishes it on the production domain; only the very first deployment of a new project always goes to production. You can keep the settings in the repository, in a vercel.json file:
1// vercel.json
2{
3 "buildCommand": "npm run build",
4 "outputDirectory": "dist",
5 "framework": "vite",
6 "rewrites": [
7 { "source": "/(.*)", "destination": "/index.html" }
8 ]
9}The buildCommand, outputDirectory and framework fields repeat what Vercel detects by itself, but they do not depend on clicks in the dashboard. The most important part is the rewrites section.
Why an SPA needs address rewriting
An application with Vue Router is an SPA: the server has only index.html, and routes such as /gallery are handled by JavaScript in the browser. When a technician refreshes the page on /gallery, the browser asks the server for a non-existent /gallery file and gets a 404. The rewrite rule tells the server to return index.html for every path, and the router displays the right view.
Deployment on Netlify
Netlify works similarly: you connect the repository or use the CLI. The netlify deploy command publishes a draft version at a preview address, and with the --prod flag the production version:
1# Install Netlify CLI
2npm install -g netlify-cli
3
4# Deploy
5netlify deploy
6
7# Production
8netlify deploy --prodThe configuration goes into netlify.toml. The [build] section gives the build command and the directory to publish, and [[redirects]] does for the SPA what rewrites does on Vercel:
1# netlify.toml
2[build]
3 command = "npm run build"
4 publish = "dist"
5
6[[redirects]]
7 from = "/*"
8 to = "/index.html"
9 status = 200Status 200 means a rewrite, so the address stays unchanged. With a 301 code the browser would jump to the home page and the technician would lose the route.
Deployment on GitHub Pages
GitHub Pages hosts a site straight from the repository. The gh-pages package pushes the contents of dist to a separate branch:
1npm install -D gh-pagesA project site lives at an address containing the repository name, for example https://user.github.io/repository-name/. That is why you set base in vite.config.js, the path from which Vite builds references to files:
1// vite.config.js
2export default defineConfig({
3 base: '/repository-name/', // Repository name
4 plugins: [vue()]
5})Without base the browser would look for scripts in the domain root and show a blank page; for a user site keep base: '/'. What remains is the deployment script:
1// package.json
2{
3 "scripts": {
4 "deploy": "npm run build && gh-pages -d dist"
5 }
6}npm run deploy builds the application and publishes dist. GitHub Pages does not support rewrite rules, so refreshing a deep SPA route returns a 404. The Vite documentation also describes deploying through GitHub Actions instead of the gh-pages package.
Docker Deployment
Your own server requires a Docker image. The Dockerfile uses a multi-stage build: the first stage, on a Node image, builds the application, and the second, on a lightweight nginx, only serves it:
1# Dockerfile
2FROM node:24-alpine AS builder
3
4WORKDIR /app
5
6COPY package*.json ./
7RUN npm ci
8
9COPY . .
10RUN npm run build
11
12FROM nginx:alpine
13
14COPY /app/dist /usr/share/nginx/html
15COPY nginx.conf /etc/nginx/conf.d/default.conf
16
17EXPOSE 80
18
19CMD ["nginx", "-g", "daemon off;"]COPY --from=builder carries only dist over from the first stage, so the source code, node_modules and Node itself stay out of the final image. The base image is Node 24, the current LTS version - Vite 8 requires at least Node 20.19 or 22.12, so the old node:18 would break the build. nginx still needs a rule for the SPA:
1# nginx.conf
2server {
3 listen 80;
4 server_name localhost;
5
6 root /usr/share/nginx/html;
7 index index.html;
8
9 location / {
10 try_files $uri $uri/ /index.html;
11 }
12
13 # Cache static assets
14 location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
15 expires 1y;
16 add_header Cache-Control "public, immutable";
17 }
18}try_files $uri $uri/ /index.html checks the file, then the directory, and as a last resort returns index.html. A one-year cache for assets is safe, because a code change changes the hash in the file name. All that is left is to build the image and run the container:
1# Build and run
2docker build -t gallery-app .
3docker run -p 8080:80 gallery-app-t gallery-app gives the image a name, and the dot points to the current directory as the build context. -p 8080:80 connects port 8080 of your computer with port 80 of the container, so the application runs at http://localhost:8080.
CI/CD with GitHub Actions
A GitHub Actions workflow is a YAML file in the .github/workflows directory, run on the events listed in the on section. This one goes through the launch procedure after every push to main:
1# .github/workflows/deploy.yml
2name: Deploy
3
4on:
5 push:
6 branches: [main]
7
8jobs:
9 deploy:
10 runs-on: ubuntu-latest
11
12 steps:
13 - uses: actions/checkout@v7
14
15 - name: Setup Node
16 uses: actions/setup-node@v7
17 with:
18 node-version: 24
19
20 - name: Install dependencies
21 run: npm ci
22
23 - name: Run tests
24 run: npm test
25
26 - name: Build
27 run: npm run build
28
29 - name: Deploy to Vercel
30 uses: amondnet/vercel-action@v42
31 with:
32 vercel-token: ${{ secrets.VERCEL_TOKEN }}
33 vercel-org-id: ${{ secrets.ORG_ID }}
34 vercel-project-id: ${{ secrets.PROJECT_ID }}
35 vercel-args: '--prod'The steps run in order: actions/checkout fetches the code, actions/setup-node installs Node, npm ci installs dependencies exactly as recorded in package-lock.json, then come the tests, the build and the deployment. When any step fails, the following ones do not run. In a full pipeline linting (npm run lint) also comes before the tests - you will see it in the project. secrets are secret keys from the repository settings, and amondnet/vercel-action is a community action - a project connected to Vercel through Git does not need this step.
Performance Checklist
- Lazy load routes - views loaded only when their route is visited
- Code splitting - libraries in separate bundles kept in the cache
- Image optimization - images usually weigh more than the code
- Minification - Vite does it by default
- Gzip/Brotli compression - server-side compression
- CDN for static assets - files closer to the user
- Cache headers - long for hashed files, short for
index.html - Preload critical resources - Vite adds
modulepreloadby itself - Remove console.logs - the minifier strips them once you enable the right option
- Source maps for dev only - publish them only deliberately, e.g.
sourcemap: 'hidden'for error reporting
My advice: to start with, choose Vercel or Netlify with Git integration - you get HTTPS, a CDN and pull request previews without any configuration. Save Docker for when the application has to stand on the station's own infrastructure. In the final project you tie tests, build and deployment into one pipeline.
Remember: CI/CD is an automatic pre-launch procedure that will not let you take off with a damaged system.
Code for this lesson: App.vue
1<script setup>
2import { ref, computed } from 'vue'
3
4// NOVA LAB - CI/CD Pipeline Visualization
5const pipelineStages = ref([
6 { id: 1, name: 'Source', status: 'completed', duration: 2, icon: '' },
7 { id: 2, name: 'Build', status: 'idle', duration: 45, icon: '' },
8 { id: 3, name: 'Test', status: 'idle', duration: 30, icon: '' },
9 { id: 4, name: 'Security Scan', status: 'idle', duration: 20, icon: '' },
10 { id: 5, name: 'Deploy Staging', status: 'idle', duration: 25, icon: '' },
11 { id: 6, name: 'Deploy Production', status: 'idle', duration: 30, icon: '' }
12])
13
14const pipelineRunning = ref(false)
15const currentStage = ref(0)
16const pipelineProgress = ref(0)
17
18const logs = ref([
19 '→ Pipeline ready to start...'
20])
21
22let stageInterval = null
23
24const overallProgress = computed(() => {
25 const completed = pipelineStages.value.filter(s => s.status === 'completed').length
26 return Math.round((completed / pipelineStages.value.length) * 100)
27})
28
29const pipelineStatus = computed(() => {
30 if (pipelineStages.value.some(s => s.status === 'failed')) return 'failed'
31 if (pipelineStages.value.every(s => s.status === 'completed')) return 'success'
32 if (pipelineRunning.value) return 'running'
33 return 'idle'
34})
35
36function startPipeline() {
37 pipelineRunning.value = true
38 currentStage.value = 0
39 pipelineProgress.value = 0
40 logs.value = ['Starting CI/CD Pipeline...']
41
42 // Reset all stages except first
43 pipelineStages.value.forEach((stage, index) => {
44 stage.status = index === 0 ? 'completed' : 'idle'
45 })
46
47 runNextStage()
48}
49
50function runNextStage() {
51 if (currentStage.value >= pipelineStages.value.length - 1) {
52 completePipeline()
53 return
54 }
55
56 currentStage.value++
57 const stage = pipelineStages.value[currentStage.value]
58 stage.status = 'running'
59
60 logs.value.push(`⏳ Running: ${stage.name}...`)
61
62 pipelineProgress.value = 0
63 const increment = 100 / (stage.duration / 10)
64
65 stageInterval = setInterval(() => {
66 pipelineProgress.value += increment
67
68 if (pipelineProgress.value >= 100) {
69 clearInterval(stageInterval)
70 stage.status = 'completed'
71 logs.value.push(`${stage.name} completed successfully`)
72
73 setTimeout(() => {
74 runNextStage()
75 }, 500)
76 }
77 }, 100)
78}
79
80function completePipeline() {
81 pipelineRunning.value = false
82 logs.value.push('Pipeline completed successfully!')
83 logs.value.push('Mars Mission deployed to production!')
84}
85
86function resetPipeline() {
87 if (stageInterval) clearInterval(stageInterval)
88 pipelineRunning.value = false
89 currentStage.value = 0
90 pipelineProgress.value = 0
91
92 pipelineStages.value.forEach((stage, index) => {
93 stage.status = index === 0 ? 'completed' : 'idle'
94 })
95
96 logs.value = ['→ Pipeline reset. Ready to start...']
97}
98</script>
99
100<template>
101 <div class="cicd-dashboard">
102 <div class="header">
103 <h1>CI/CD Pipeline Monitor</h1>
104 <p>NOVA LAB Automated Deployment System</p>
105 </div>
106
107 <div class="pipeline-status" :class="pipelineStatus">
108 <div class="status-indicator">
109 <template v-if="pipelineStatus === 'idle'">Idle</template>
110 <template v-else-if="pipelineStatus === 'running'">Running</template>
111 <template v-else-if="pipelineStatus === 'success'">Success</template>
112 <template v-else-if="pipelineStatus === 'failed'">Failed</template>
113 </div>
114 <div class="overall-progress">
115 Progress: {{ overallProgress }}%
116 </div>
117 </div>
118
119 <div class="pipeline-visualization">
120 <div
121 v-for="stage in pipelineStages"
122 :key="stage.id"
123 class="pipeline-stage"
124 :class="stage.status"
125 >
126 <div class="stage-icon">{{ stage.icon }}</div>
127 <div class="stage-name">{{ stage.name }}</div>
128 <div class="stage-status">
129 <template v-if="stage.status === 'idle'"></template>
130 <template v-else-if="stage.status === 'running'"></template>
131 <template v-else-if="stage.status === 'completed'"></template>
132 <template v-else-if="stage.status === 'failed'"></template>
133 </div>
134 <div v-if="stage.status === 'running'" class="stage-progress">
135 <div class="progress-bar">
136 <div class="progress-fill" :style="{ width: pipelineProgress + '%' }"></div>
137 </div>
138 </div>
139 </div>
140 </div>
141
142 <div class="logs-panel">
143 <h3>Pipeline Logs</h3>
144 <div class="logs">
145 <div v-for="(log, index) in logs" :key="index" class="log-entry">
146 {{ log }}
147 </div>
148 </div>
149 </div>
150
151 <div class="controls">
152 <button
153 @click="startPipeline"
154 :disabled="pipelineRunning"
155 class="start-btn"
156 >
157 ▶Start Pipeline
158 </button>
159 <button
160 @click="resetPipeline"
161 class="reset-btn"
162 >
163 Reset
164 </button>
165 </div>
166 </div>
167</template>
168
169<style scoped>
170.cicd-dashboard {
171 background: linear-gradient(135deg, #0a0e27 0%, #1a1e3f 100%);
172 color: #00ff88;
173 padding: 2rem;
174 min-height: 100vh;
175 font-family: 'Courier New', monospace;
176}
177
178.header {
179 text-align: center;
180 margin-bottom: 2rem;
181 border-bottom: 2px solid #00b4d8;
182 padding-bottom: 1rem;
183}
184
185.header h1 {
186 color: #00ff88;
187 text-shadow: 0 0 10px #00ff88;
188 margin: 0;
189}
190
191.pipeline-status {
192 display: flex;
193 justify-content: space-between;
194 align-items: center;
195 padding: 1.5rem;
196 border-radius: 8px;
197 margin-bottom: 2rem;
198 border: 2px solid;
199}
200
201.pipeline-status.idle {
202 background: rgba(128, 128, 128, 0.1);
203 border-color: #888;
204}
205
206.pipeline-status.running {
207 background: rgba(0, 180, 216, 0.2);
208 border-color: #00b4d8;
209 animation: pulse 2s infinite;
210}
211
212.pipeline-status.success {
213 background: rgba(0, 255, 136, 0.2);
214 border-color: #00ff88;
215}
216
217.pipeline-status.failed {
218 background: rgba(255, 0, 0, 0.2);
219 border-color: #ff0000;
220}
221
222.status-indicator {
223 font-size: 1.3rem;
224 font-weight: bold;
225}
226
227.pipeline-visualization {
228 display: flex;
229 flex-direction: column;
230 gap: 1rem;
231 margin-bottom: 2rem;
232}
233
234.pipeline-stage {
235 background: rgba(0, 180, 216, 0.1);
236 border: 2px solid #00b4d8;
237 border-radius: 8px;
238 padding: 1rem;
239 display: grid;
240 grid-template-columns: 50px 1fr 50px;
241 align-items: center;
242 gap: 1rem;
243 transition: all 0.3s;
244}
245
246.pipeline-stage.running {
247 border-color: #00b4d8;
248 background: rgba(0, 180, 216, 0.2);
249 box-shadow: 0 0 20px rgba(0, 180, 216, 0.3);
250}
251
252.pipeline-stage.completed {
253 border-color: #00ff88;
254 background: rgba(0, 255, 136, 0.1);
255}
256
257.stage-icon {
258 font-size: 2rem;
259 text-align: center;
260}
261
262.stage-name {
263 color: #fff;
264 font-size: 1.1rem;
265}
266
267.stage-status {
268 font-size: 1.5rem;
269 text-align: center;
270}
271
272.stage-progress {
273 grid-column: 1 / -1;
274 margin-top: 0.5rem;
275}
276
277.progress-bar {
278 height: 8px;
279 background: rgba(0, 255, 136, 0.1);
280 border-radius: 4px;
281 overflow: hidden;
282}
283
284.progress-fill {
285 height: 100%;
286 background: linear-gradient(90deg, #00b4d8, #00ff88);
287 transition: width 0.3s;
288}
289
290.logs-panel {
291 background: rgba(0, 180, 216, 0.1);
292 border: 1px solid #00b4d8;
293 border-radius: 8px;
294 padding: 1.5rem;
295 margin-bottom: 2rem;
296}
297
298.logs-panel h3 {
299 color: #00b4d8;
300 margin-top: 0;
301}
302
303.logs {
304 background: #000;
305 border-radius: 4px;
306 padding: 1rem;
307 max-height: 200px;
308 overflow-y: auto;
309 font-family: monospace;
310}
311
312.log-entry {
313 color: #00ff88;
314 margin-bottom: 0.5rem;
315 font-size: 0.9rem;
316}
317
318.controls {
319 display: flex;
320 gap: 1rem;
321 justify-content: center;
322}
323
324.start-btn, .reset-btn {
325 padding: 1rem 2rem;
326 font-size: 1rem;
327 font-family: 'Courier New', monospace;
328 border-radius: 8px;
329 cursor: pointer;
330 border: 2px solid;
331 transition: all 0.3s;
332}
333
334.start-btn {
335 background: #00ff88;
336 color: #0a0e27;
337 border-color: #00ff88;
338}
339
340.start-btn:disabled {
341 opacity: 0.5;
342 cursor: not-allowed;
343}
344
345.start-btn:hover:not(:disabled) {
346 box-shadow: 0 0 20px #00ff88;
347}
348
349.reset-btn {
350 background: rgba(0, 180, 216, 0.2);
351 color: #00b4d8;
352 border-color: #00b4d8;
353}
354
355.reset-btn:hover {
356 background: #00b4d8;
357 color: #0a0e27;
358}
359</style>Spotted a mistake in this lesson?
Check yourself
Answer the questions from this lesson. Pick an answer to see right away whether it is correct.
1. How do you deploy a Vue application to the Vercel platform?
2. What build settings are needed for deployment on Netlify?
These are 2 of 4 questions for this lesson. Solve the rest in the game.
Hands-on tasks in the game
- Code editor
Complete the vercel.json file with buildCommand, outputDirectory, framework, and rewrites for SPA configuration.
- Vertical ordering
Arrange the CI/CD pipeline stages from first to last:
- Vertical ordering
Arrange the CI/CD pipeline stages from code to production:
- Click in order
Arrange the Docker image build command:
- Horizontal ordering
Arrange the Vite configuration for GitHub Pages:
- Code editor
Complete a Dockerfile with a multi-stage build: the build stage (node) and the production stage (nginx).