Project: Mars Mission Launch - Complete Production App
Vue.js course · Module 12: Testing & Deploy
The final application is ready for launch! Your code is flying to Mars!
Over five lessons you assembled the launch procedure from separate elements: component tests, logic tests, E2E, the build and deployment. Each worked in its own lesson, but a real project needs all of them at once, in one repository, tied together so that no step depends on the operator's memory. This project is a complete production configuration of the gallery - we will go through it file by file.
A launch-ready Vite configuration
The vite.config.js file starts with three plugins: vue() compiles the components, ViteImageOptimizer compresses images, and visualizer draws a bundle size report and opens it in the browser right away:
1// vite.config.js - Production ready
2import { defineConfig } from 'vite'
3import vue from '@vitejs/plugin-vue'
4import { ViteImageOptimizer } from 'vite-plugin-image-optimizer'
5import { visualizer } from 'rollup-plugin-visualizer'
6
7export default defineConfig({
8 plugins: [
9 vue(),
10 ViteImageOptimizer({
11 jpg: { quality: 80 },
12 png: { quality: 80 },
13 webp: { quality: 80 }
14 }),
15 visualizer({
16 open: true,
17 gzipSize: true
18 })
19 ],The plugins do not touch the files in src - they work on what travels to dist. The second part of the file configures the build: the terser minifier, bundle splitting and the development server port:
1 // vite.config.js - continuation of defineConfig
2 build: {
3 outDir: 'dist',
4 sourcemap: false,
5 minify: 'terser',
6
7 terserOptions: {
8 compress: {
9 drop_console: true,
10 drop_debugger: true
11 }
12 },
13
14 rolldownOptions: {
15 output: {
16 codeSplitting: {
17 groups: [
18 { name: 'vue-vendor', test: /node_modules[\\/](vue|@vue|vue-router|pinia)[\\/]/ },
19 { name: 'utils', test: /node_modules[\\/]@vueuse[\\/]/ }
20 ]
21 }
22 }
23 }
24 },
25
26 server: {
27 port: 3000,
28 open: true
29 }
30})The drop_console and drop_debugger options in terserOptions remove all console.log calls and debugger statements from the production code; the source files stay untouched. Terser has to be installed separately (npm install -D terser), and Vite 8's default minifier, Oxc, does the same with the dropConsole option in build.rolldownOptions.output.minify.compress. Splitting is described by codeSplitting: each group has a bundle name and a test, a regular expression matched against the module path, in which [\\/] catches both the slash and the backslash of Windows paths. Older configurations with rollupOptions.output.manualChunks in object form no longer work - Vite 8 aborts the build on them.
Scripts in package.json
The package.json file gathers the launch procedure commands in one place - every pipeline stage will later call one of these scripts:
1// package.json
2{
3 "name": "gallery-app",
4 "version": "1.0.0",
5 "scripts": {
6 "dev": "vite",
7 "build": "vite build",
8 "preview": "vite preview",
9 "test": "vitest",
10 "test:ui": "vitest --ui",
11 "test:coverage": "vitest --coverage",
12 "lint": "eslint . --ext .vue,.js,.ts",
13 "format": "prettier --write .",
14 "type-check": "vue-tsc --noEmit",
15 "deploy": "npm run build && vercel --prod"
16 }
17}lint inspects the code with ESLint, format tidies it with Prettier, and type-check runs vue-tsc, that is TypeScript type checking that also covers .vue files. deploy ships the build with vercel --prod, which is equivalent to vercel deploy --prod.
Lazy loading components
A heavy component, for example a telemetry chart opened on demand, does not have to sit in the main bundle. The defineAsyncComponent function from the vue package takes a function with a dynamic import() and returns a component that is downloaded on its first render:
1import { defineAsyncComponent } from 'vue'
2
3const TelemetryChart = defineAsyncComponent(() =>
4 import('./components/TelemetryChart.vue')
5)The build moves TelemetryChart.vue into a separate bundle, and in the template you use it like any other component. Router routes only need a plain () => import().
CI/CD pipeline
The workflow divides the work into three jobs. The first one, test, runs on every push and pull request and performs a full quality check:
1# .github/workflows/ci.yml
2name: CI/CD
3
4on:
5 push:
6 branches: [main, develop]
7 pull_request:
8 branches: [main]
9
10jobs:
11 test:
12 runs-on: ubuntu-latest
13
14 steps:
15 - uses: actions/checkout@v7
16
17 - name: Setup Node
18 uses: actions/setup-node@v7
19 with:
20 node-version: 24
21 cache: 'npm'
22
23 - name: Install dependencies
24 run: npm ci
25
26 - name: Lint
27 run: npm run lint
28
29 - name: Type check
30 run: npm run type-check
31
32 - name: Run tests
33 run: npm run test:coverage
34
35 - name: Upload coverage
36 uses: codecov/codecov-action@v7
37 with:
38 token: ${{ secrets.CODECOV_TOKEN }}The order is deliberate: the cheapest checks come first. cache: 'npm' keeps the packages between runs, and the Codecov action uploads the coverage report with a token from the repository secrets. The build job waits for a green test thanks to the needs field and saves dist as an artifact:
1 build:
2 runs-on: ubuntu-latest
3 needs: test
4
5 steps:
6 - uses: actions/checkout@v7
7
8 - name: Setup Node
9 uses: actions/setup-node@v7
10 with:
11 node-version: 24
12 cache: 'npm'
13
14 - name: Install dependencies
15 run: npm ci
16
17 - name: Build
18 run: npm run build
19
20 - name: Upload build artifacts
21 uses: actions/upload-artifact@v7
22 with:
23 name: dist
24 path: distAn artifact is a file GitHub stores between jobs. Version v3 of the artifact actions stopped working on January 30, 2025, which is why old workflows fail today. The last job deploys the application only from the main branch, which the if condition checks:
1 deploy:
2 runs-on: ubuntu-latest
3 needs: build
4 if: github.ref == 'refs/heads/main'
5
6 steps:
7 - uses: actions/checkout@v7
8
9 - name: Download build artifacts
10 uses: actions/download-artifact@v8
11 with:
12 name: dist
13 path: dist
14
15 - name: Deploy to Vercel
16 uses: amondnet/vercel-action@v42
17 with:
18 vercel-token: ${{ secrets.VERCEL_TOKEN }}
19 vercel-org-id: ${{ secrets.ORG_ID }}
20 vercel-project-id: ${{ secrets.PROJECT_ID }}
21 vercel-args: '--prod'Pull requests therefore go through tests and the build without a deployment. The downloaded artifact is useful for static hosting, because Vercel by default builds the project on its side, from the uploaded code.
The Docker capsule
The container version is the Dockerfile from the previous lesson with comments and a HEALTHCHECK that checks every 30 seconds whether nginx responds:
1# Dockerfile - Multi-stage build
2FROM node:24-alpine AS builder
3
4WORKDIR /app
5
6# Copy package files
7COPY package*.json ./
8
9# Install dependencies (the build needs devDependencies too)
10RUN npm ci
11
12# Copy source
13COPY . .
14
15# Build
16RUN npm run build
17
18# Production stage
19FROM nginx:alpine
20
21# Copy build
22COPY /app/dist /usr/share/nginx/html
23
24# Copy nginx config
25COPY nginx.conf /etc/nginx/conf.d/default.conf
26
27# Health check
28HEALTHCHECK \
29 CMD wget --quiet --tries=1 --spider http://localhost/ || exit 1
30
31EXPOSE 80
32
33CMD ["nginx", "-g", "daemon off;"]Watch out for a common mistake: npm ci --only=production in the build stage skips devDependencies, where Vite lives, so the build would fail. Only dist ends up in the final image anyway.
Optimized nginx
The server configuration adds gzip compression, security headers and separate cache rules:
1# nginx.conf - Optimized
2server {
3 listen 80;
4 server_name _;
5 root /usr/share/nginx/html;
6 index index.html;
7
8 # Gzip
9 gzip on;
10 gzip_vary on;
11 gzip_types text/plain text/css text/xml text/javascript
12 application/x-javascript application/xml+rss
13 application/javascript application/json;
14
15 # Security headers
16 add_header X-Frame-Options "SAMEORIGIN" always;
17 add_header X-Content-Type-Options "nosniff" always;
18 # Deprecated header: 0 disables the legacy XSS filter, use a Content-Security-Policy instead
19 add_header X-XSS-Protection "0" always;
20
21 # SPA routing
22 location / {
23 try_files $uri $uri/ /index.html;
24 }
25
26 # Cache static assets
27 location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
28 expires 1y;
29 add_header Cache-Control "public, immutable";
30 }
31
32 # Don't cache index.html
33 location = /index.html {
34 add_header Cache-Control "no-cache, must-revalidate";
35 }
36}index.html must not end up in a long cache, because it points to the current bundle names. X-XSS-Protection is deprecated and MDN recommends Content-Security-Policy instead, hence the value 0. A trap: add_header at the server level is not passed down to location blocks that have their own add_header, so the security headers must be repeated there.
Monitoring and SEO
After launch you watch the instruments: performance profiling in Vue DevTools shows components that render too often, Lighthouse audits page loading, and Web Vitals metrics (LCP, INP, CLS) reflect the experience of real users. An SPA sends search engine bots an empty index.html, and JavaScript draws the content. Google and Bing cope with simple applications, but they do not wait for data fetched asynchronously, so only SSR or SSG gives bots the full HTML, most easily with Nuxt - you will meet it at the Quantum Laboratory. Before a manual launch keep the pipeline order: lint and type checking, tests, npm run build, a preview with npm run preview, and vercel --prod at the very end.
CONGRATULATIONS! You have completed your training at the NOVA LAB Launch Platform!
Your code has passed all pre-launch tests and is ready for the Mars mission!
Your application now:
- Is fully tested (all systems checked)
- Is optimized for performance (ready for harsh space conditions)
- Is ready for deployment (launch procedure complete)
- Has a CI/CD pipeline (automatic control systems)
- Runs in a Docker container (a sealed capsule)
LAUNCH MISSION ACCOMPLISHED!
My advice: copy this set of files into your own repository and adapt it to your application - it is a skeleton for every future Vue project. Your code is launching to Mars, and three more NOVA LAB locations lie ahead of you; in the Holographic Chamber you will bring the interface to life with animations.
Remember: an application is ready for flight only when tests, build and deployment run automatically, without the operator.
Code for this lesson: App.vue
1<script setup>
2import { ref, computed, onMounted } from 'vue'
3
4// NOVA LAB - Monitoring Dashboard
5const metrics = ref({
6 uptime: 99.9,
7 responseTime: 145,
8 errorRate: 0.02,
9 activeUsers: 1247,
10 requestsPerMinute: 3542,
11 cpuUsage: 45,
12 memoryUsage: 62,
13 diskUsage: 38
14})
15
16const alerts = ref([
17 { id: 1, level: 'warning', message: 'High memory usage detected', time: '2 min ago' },
18 { id: 2, level: 'info', message: 'Deployment completed successfully', time: '15 min ago' }
19])
20
21const performanceData = ref([
22 { time: '00:00', value: 120 },
23 { time: '04:00', value: 95 },
24 { time: '08:00', value: 180 },
25 { time: '12:00', value: 220 },
26 { time: '16:00', value: 195 },
27 { time: '20:00', value: 150 }
28])
29
30const healthStatus = computed(() => {
31 const { uptime, errorRate, responseTime } = metrics.value
32
33 if (uptime >= 99.5 && errorRate < 0.1 && responseTime < 200) {
34 return { status: 'healthy', label: 'Healthy', color: '#00ff88' }
35 } else if (uptime >= 99 && errorRate < 0.5 && responseTime < 500) {
36 return { status: 'warning', label: 'Warning', color: '#ffa500' }
37 } else {
38 return { status: 'critical', label: 'Critical', color: '#ff0000' }
39 }
40})
41
42const performanceScore = computed(() => {
43 const avgResponseTime = metrics.value.responseTime
44 if (avgResponseTime < 100) return 'Excellent'
45 if (avgResponseTime < 200) return 'Good'
46 if (avgResponseTime < 500) return 'Fair'
47 return 'Poor'
48})
49
50function refreshMetrics() {
51 // Simulate metrics update
52 metrics.value.responseTime = Math.floor(Math.random() * 100) + 100
53 metrics.value.activeUsers = Math.floor(Math.random() * 500) + 1000
54 metrics.value.requestsPerMinute = Math.floor(Math.random() * 1000) + 3000
55 metrics.value.cpuUsage = Math.floor(Math.random() * 30) + 30
56 metrics.value.memoryUsage = Math.floor(Math.random() * 20) + 50
57}
58
59onMounted(() => {
60 // Auto-refresh every 5 seconds
61 setInterval(refreshMetrics, 5000)
62})
63</script>
64
65<template>
66 <div class="monitoring-dashboard">
67 <div class="header">
68 <h1>Mission Control Dashboard</h1>
69 <p>NOVA LAB Real-Time Monitoring</p>
70 </div>
71
72 <div class="health-status" :style="{ borderColor: healthStatus.color }">
73 <div class="status-icon" :style="{ color: healthStatus.color }">
74 {{ healthStatus.status === 'healthy' ? '' : healthStatus.status === 'warning' ? '' : '' }}
75 </div>
76 <div class="status-info">
77 <h2>System Health: {{ healthStatus.label }}</h2>
78 <p>Uptime: {{ metrics.uptime }}% | Performance: {{ performanceScore }}</p>
79 </div>
80 </div>
81
82 <div class="metrics-grid">
83 <div class="metric-card">
84 <div class="metric-icon"></div>
85 <div class="metric-value">{{ metrics.activeUsers.toLocaleString() }}</div>
86 <div class="metric-label">Active Users</div>
87 </div>
88
89 <div class="metric-card">
90 <div class="metric-icon"></div>
91 <div class="metric-value">{{ metrics.responseTime }}ms</div>
92 <div class="metric-label">Avg Response Time</div>
93 </div>
94
95 <div class="metric-card">
96 <div class="metric-icon"></div>
97 <div class="metric-value">{{ metrics.requestsPerMinute.toLocaleString() }}</div>
98 <div class="metric-label">Requests/Min</div>
99 </div>
100
101 <div class="metric-card">
102 <div class="metric-icon"></div>
103 <div class="metric-value">{{ metrics.errorRate }}%</div>
104 <div class="metric-label">Error Rate</div>
105 </div>
106 </div>
107
108 <div class="resource-usage">
109 <h3>Resource Usage</h3>
110 <div class="resource-bars">
111 <div class="resource-item">
112 <div class="resource-header">
113 <span>CPU</span>
114 <span>{{ metrics.cpuUsage }}%</span>
115 </div>
116 <div class="resource-bar">
117 <div
118 class="resource-fill"
119 :style="{
120 width: metrics.cpuUsage + '%',
121 background: metrics.cpuUsage > 80 ? '#ff0000' : metrics.cpuUsage > 60 ? '#ffa500' : '#00ff88'
122 }"
123 ></div>
124 </div>
125 </div>
126
127 <div class="resource-item">
128 <div class="resource-header">
129 <span>Memory</span>
130 <span>{{ metrics.memoryUsage }}%</span>
131 </div>
132 <div class="resource-bar">
133 <div
134 class="resource-fill"
135 :style="{
136 width: metrics.memoryUsage + '%',
137 background: metrics.memoryUsage > 80 ? '#ff0000' : metrics.memoryUsage > 60 ? '#ffa500' : '#00ff88'
138 }"
139 ></div>
140 </div>
141 </div>
142
143 <div class="resource-item">
144 <div class="resource-header">
145 <span>Disk</span>
146 <span>{{ metrics.diskUsage }}%</span>
147 </div>
148 <div class="resource-bar">
149 <div
150 class="resource-fill"
151 :style="{
152 width: metrics.diskUsage + '%',
153 background: metrics.diskUsage > 80 ? '#ff0000' : metrics.diskUsage > 60 ? '#ffa500' : '#00ff88'
154 }"
155 ></div>
156 </div>
157 </div>
158 </div>
159 </div>
160
161 <div class="alerts-panel">
162 <h3>Recent Alerts</h3>
163 <div class="alerts-list">
164 <div
165 v-for="alert in alerts"
166 :key="alert.id"
167 class="alert-item"
168 :class="alert.level"
169 >
170 <span class="alert-icon">
171 {{ alert.level === 'warning' ? '' : alert.level === 'error' ? '' : 'ℹ' }}
172 </span>
173 <span class="alert-message">{{ alert.message }}</span>
174 <span class="alert-time">{{ alert.time }}</span>
175 </div>
176 </div>
177 </div>
178
179 <div class="actions">
180 <button @click="refreshMetrics" class="refresh-btn">
181 Refresh Metrics
182 </button>
183 </div>
184 </div>
185</template>
186
187<style scoped>
188.monitoring-dashboard {
189 background: #0a0e27;
190 color: #00ff88;
191 padding: 2rem;
192 min-height: 100vh;
193 font-family: 'Courier New', monospace;
194}
195
196.header {
197 text-align: center;
198 margin-bottom: 2rem;
199 border-bottom: 2px solid #00b4d8;
200 padding-bottom: 1rem;
201}
202
203.header h1 {
204 color: #00ff88;
205 text-shadow: 0 0 10px #00ff88;
206 margin: 0;
207}
208
209.health-status {
210 display: flex;
211 align-items: center;
212 gap: 1.5rem;
213 background: rgba(0, 180, 216, 0.1);
214 border: 3px solid;
215 border-radius: 12px;
216 padding: 1.5rem;
217 margin-bottom: 2rem;
218}
219
220.status-icon {
221 font-size: 3rem;
222}
223
224.status-info h2 {
225 margin: 0 0 0.5rem 0;
226 color: #fff;
227}
228
229.status-info p {
230 margin: 0;
231 color: #00b4d8;
232}
233
234.metrics-grid {
235 display: grid;
236 grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
237 gap: 1.5rem;
238 margin-bottom: 2rem;
239}
240
241.metric-card {
242 background: rgba(0, 180, 216, 0.1);
243 border: 2px solid #00b4d8;
244 border-radius: 8px;
245 padding: 1.5rem;
246 text-align: center;
247 transition: transform 0.3s;
248}
249
250.metric-card:hover {
251 transform: translateY(-5px);
252 box-shadow: 0 5px 20px rgba(0, 180, 216, 0.3);
253}
254
255.metric-icon {
256 font-size: 2.5rem;
257 margin-bottom: 0.5rem;
258}
259
260.metric-value {
261 font-size: 2rem;
262 color: #00ff88;
263 font-weight: bold;
264 margin-bottom: 0.5rem;
265}
266
267.metric-label {
268 color: #00b4d8;
269 font-size: 0.9rem;
270}
271
272.resource-usage, .alerts-panel {
273 background: rgba(0, 180, 216, 0.1);
274 border: 1px solid #00b4d8;
275 border-radius: 8px;
276 padding: 1.5rem;
277 margin-bottom: 1.5rem;
278}
279
280.resource-usage h3, .alerts-panel h3 {
281 color: #00b4d8;
282 margin-top: 0;
283}
284
285.resource-bars {
286 display: flex;
287 flex-direction: column;
288 gap: 1rem;
289}
290
291.resource-item {
292 display: flex;
293 flex-direction: column;
294 gap: 0.5rem;
295}
296
297.resource-header {
298 display: flex;
299 justify-content: space-between;
300 color: #fff;
301 font-size: 0.9rem;
302}
303
304.resource-bar {
305 height: 20px;
306 background: rgba(0, 255, 136, 0.1);
307 border-radius: 10px;
308 overflow: hidden;
309 border: 1px solid #00b4d8;
310}
311
312.resource-fill {
313 height: 100%;
314 transition: width 0.5s, background 0.5s;
315 border-radius: 10px;
316}
317
318.alerts-list {
319 display: flex;
320 flex-direction: column;
321 gap: 0.75rem;
322}
323
324.alert-item {
325 display: flex;
326 align-items: center;
327 gap: 1rem;
328 padding: 1rem;
329 border-radius: 8px;
330 border-left: 4px solid;
331}
332
333.alert-item.warning {
334 background: rgba(255, 165, 0, 0.1);
335 border-color: #ffa500;
336}
337
338.alert-item.error {
339 background: rgba(255, 0, 0, 0.1);
340 border-color: #ff0000;
341}
342
343.alert-item.info {
344 background: rgba(0, 180, 216, 0.1);
345 border-color: #00b4d8;
346}
347
348.alert-icon {
349 font-size: 1.5rem;
350}
351
352.alert-message {
353 flex: 1;
354 color: #fff;
355}
356
357.alert-time {
358 color: #00b4d8;
359 font-size: 0.85rem;
360}
361
362.actions {
363 text-align: center;
364}
365
366.refresh-btn {
367 padding: 1rem 2rem;
368 background: rgba(0, 180, 216, 0.2);
369 border: 2px solid #00b4d8;
370 color: #00b4d8;
371 font-size: 1rem;
372 font-family: 'Courier New', monospace;
373 border-radius: 8px;
374 cursor: pointer;
375 transition: all 0.3s;
376}
377
378.refresh-btn:hover {
379 background: #00b4d8;
380 color: #0a0e27;
381 box-shadow: 0 0 20px #00b4d8;
382}
383</style>Check yourself
Answer the questions from this lesson. Pick an answer to see right away whether it is correct.
1. How do you implement lazy loading of a component in Vue 3?
2. What tools help monitor the performance of a Vue application?
These are 2 of 4 questions for this lesson. Solve the rest in the game.
Hands-on tasks in the game
- Horizontal ordering
Arrange the syntax for lazy loading a component:
- Code editor
Configure a complete vite.config.js with: vue plugin, image optimizer, visualizer, build options with bundle splitting (in Vite 8 through codeSplitting instead of manualChunks).
- Vertical ordering
Arrange the production preparation steps from first to last:
- Click in order
Arrange the production deployment command for Vercel:
- Vertical ordering
Arrange the GitHub Actions CI/CD workflow steps in order:
- Click in order
Arrange the production deployment command for Netlify:
- Horizontal ordering
Arrange the nginx directive for SPA routing:
- Click in order
Arrange the Docker container run command with port mapping: