Vue.js course · Module 12: Testing & Deploy

Deployment

7 min read
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 --prod

A 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 --prod

The 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 = 200

Status 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-pages

A 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 --from=builder /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 modulepreload by 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. 1. How do you deploy a Vue application to the Vercel platform?

  2. 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).

Useful articles