Kurs Vue.js · Moduł 12: Testy i wdrożenie

Deployment

6 min czytania
W tej lekcji7

Wdrożenie aplikacji na serwer - start rakiety! Twój kod leci do produkcji jak rakieta na Marsa!

Folder dist z poprzedniej lekcji leży na Twoim dysku. Trzeba go wynieść na orbitę: na serwer, który pod stałym adresem i przez HTTPS poda pliki każdemu technikowi - i to bez ręcznego kopiowania przy każdej poprawce. Odpowiedzią jest CI/CD, czyli Continuous Integration i Continuous Deployment - automatyzacja testów, buildu i wdrożeń, która po każdym wypchnięciu kodu sama przechodzi procedurę startową.

Deployment na Vercel

Najprościej podłączyć repozytorium Git w panelu Vercela. Vercel sam wykryje projekt Vite i ustawi komendę npm run build oraz katalog dist, a potem wdroży produkcję po każdym pushu do głównej gałęzi i przygotuje adres podglądu dla każdego pull requesta. Gdy wolisz terminal, użyj Vercel CLI:

1# Install Vercel CLI
2npm install -g vercel
3
4# Deploy
5vercel
6
7# Production deployment
8vercel --prod

Samo vercel to skrót od vercel deploy, bo deploy jest domyślnym poleceniem CLI. Bez flagi powstaje wdrożenie podglądowe pod unikalnym adresem, a --prod publikuje je pod domeną produkcyjną; tylko pierwsze wdrożenie nowego projektu zawsze trafia na produkcję. Ustawienia możesz trzymać w repozytorium, w pliku vercel.json:

1// vercel.json
2{
3  "buildCommand": "npm run build",
4  "outputDirectory": "dist",
5  "framework": "vite",
6  "rewrites": [
7    { "source": "/(.*)", "destination": "/index.html" }
8  ]
9}

Pola buildCommand, outputDirectory i framework powtarzają to, co Vercel wykrywa sam, ale nie zależą od kliknięć w panelu. Najważniejsza jest sekcja rewrites.

Dlaczego SPA potrzebuje przepisywania adresów

Aplikacja z Vue Router to SPA: serwer ma tylko index.html, a trasy w rodzaju /gallery obsługuje JavaScript w przeglądarce. Gdy technik odświeży stronę na /gallery, przeglądarka poprosi serwer o nieistniejący plik /gallery i dostanie 404. Reguła przepisywania każe dla każdej ścieżki oddać index.html, a router wyświetli właściwy widok.

Deployment na Netlify

Netlify działa podobnie: podłączasz repozytorium albo korzystasz z CLI. Polecenie netlify deploy publikuje wersję roboczą pod adresem podglądu, a z flagą --prod wersję produkcyjną:

1# Install Netlify CLI
2npm install -g netlify-cli
3
4# Deploy
5netlify deploy
6
7# Production
8netlify deploy --prod

Konfiguracja trafia do netlify.toml. Sekcja [build] podaje komendę budowania i katalog do publikacji, a [[redirects]] robi dla SPA to samo, co rewrites na Vercelu:

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 oznacza przepisanie, więc adres zostaje bez zmian. Z kodem 301 przeglądarka przeskoczyłaby na stronę główną i technik zgubiłby trasę.

Deployment na GitHub Pages

GitHub Pages hostuje stronę prosto z repozytorium. Paczka gh-pages wysyła zawartość dist na osobną gałąź:

1npm install -D gh-pages

Strona projektu działa pod adresem z nazwą repozytorium, na przykład https://user.github.io/repository-name/. Dlatego w vite.config.js ustawiasz base, czyli ścieżkę, od której Vite zbuduje odwołania do plików:

1// vite.config.js
2export default defineConfig({
3  base: '/repository-name/', // Nazwa repo
4  plugins: [vue()]
5})

Bez base przeglądarka szukałaby skryptów w katalogu głównym domeny i pokazałaby pustą stronę; dla strony użytkownika zostaw base: '/'. Pozostaje skrypt wdrożenia:

1// package.json
2{
3  "scripts": {
4    "deploy": "npm run build && gh-pages -d dist"
5  }
6}

npm run deploy buduje aplikację i publikuje dist. GitHub Pages nie obsługuje reguł przepisywania, więc odświeżenie głębokiej trasy SPA da 404. Dokumentacja Vite opisuje też wdrażanie przez GitHub Actions zamiast paczki gh-pages.

Docker Deployment

Własny serwer wymaga obrazu Docker. Dockerfile używa budowania wieloetapowego (multi-stage build): pierwszy etap na obrazie Node buduje aplikację, drugi na lekkim nginx tylko ją serwuje:

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 przenosi z pierwszego etapu wyłącznie dist, więc kod źródłowy, node_modules i sam Node nie trafiają do końcowego obrazu. Obraz bazowy to Node 24, aktualna wersja LTS - Vite 8 wymaga co najmniej Node 20.19 lub 22.12, więc dawny node:18 przerwałby build. nginx potrzebuje jeszcze reguły dla 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 sprawdza po kolei plik, katalog, a w ostateczności oddaje index.html. Roczne cache dla zasobów jest bezpieczne, bo zmiana kodu zmienia hash w nazwie pliku. Zostaje zbudować obraz i uruchomić kontener:

1# Build and run
2docker build -t gallery-app .
3docker run -p 8080:80 gallery-app

-t gallery-app nadaje obrazowi nazwę, a kropka wskazuje bieżący katalog jako kontekst budowania. -p 8080:80 łączy port 8080 komputera z portem 80 kontenera, więc aplikacja działa pod http://localhost:8080.

CI/CD with GitHub Actions

Workflow GitHub Actions to plik YAML w katalogu .github/workflows, uruchamiany przy zdarzeniach z sekcji on. Ten przechodzi procedurę startową po każdym pushu do 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'

Kroki idą po kolei: actions/checkout pobiera kod, actions/setup-node instaluje Node, npm ci instaluje zależności dokładnie według package-lock.json, potem testy, build i wdrożenie. Gdy któryś krok zawiedzie, następne się nie wykonają. W pełnym pipeline przed testami stoi jeszcze linting (npm run lint) - zobaczysz go w projekcie. secrets to tajne klucze z ustawień repozytorium, a amondnet/vercel-action to akcja społeczności - projekt podłączony do Vercela przez Git tego kroku nie potrzebuje.

Performance Checklist

  • Lazy load routes - widoki ładowane dopiero przy wejściu na trasę
  • Code splitting - biblioteki w osobnych pakietach trzymanych w cache
  • Image optimization - obrazy ważą zwykle więcej niż kod
  • Minification - Vite robi ją domyślnie
  • Gzip/Brotli compression - kompresja po stronie serwera
  • CDN dla statycznych zasobów - pliki bliżej użytkownika
  • Cache headers - długie dla plików z hashem, krótkie dla index.html
  • Preload critical resources - Vite sam dodaje modulepreload
  • Remove console.logs - minifikator wytnie je po włączeniu odpowiedniej opcji
  • Source maps tylko dla dev - publicznie tylko świadomie, np. sourcemap: 'hidden' dla raportowania błędów

Moja rada: na start wybierz Vercel albo Netlify z integracją Git - HTTPS, CDN i podglądy pull requestów dostajesz bez konfiguracji. Dockera zostaw na czas, gdy aplikacja musi stanąć na infrastrukturze stacji. W projekcie końcowym spinasz testy, build i wdrożenie w jeden pipeline.

Zapamiętaj: CI/CD to automatyczna procedura przedstartowa, która nie pozwoli wystartować z uszkodzonym systemem.

Kod do tej lekcji: App.vue
1<script setup>
2import { ref, computed } from 'vue'
3
4// NOVA LAB - wizualizacja pipeline CI/CD
5const pipelineStages = ref([
6  { id: 1, name: 'Kod zrodlowy', status: 'completed', duration: 2, icon: '' },
7  { id: 2, name: 'Build', status: 'idle', duration: 45, icon: '' },
8  { id: 3, name: 'Testy', status: 'idle', duration: 30, icon: '' },
9  { id: 4, name: 'Skan bezpieczenstwa', status: 'idle', duration: 20, icon: '' },
10  { id: 5, name: 'Wdrozenie staging', status: 'idle', duration: 25, icon: '' },
11  { id: 6, name: 'Wdrozenie produkcyjne', 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 gotowy do startu...'
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 = ['Start pipeline CI/CD...']
41
42  // Reset wszystkich etapow poza pierwszym
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(`→ W toku: ${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}: zakonczono pomyslnie`)
72
73      setTimeout(() => {
74        runNextStage()
75      }, 500)
76    }
77  }, 100)
78}
79
80function completePipeline() {
81  pipelineRunning.value = false
82  logs.value.push('Pipeline zakonczony pomyslnie!')
83  logs.value.push('Misja marsjanska wdrozona na produkcje!')
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 zresetowany. Gotowy do startu...']
97}
98</script>
99
100<template>
101  <div class="cicd-dashboard">
102    <div class="header">
103      <h1>Monitor pipeline CI/CD</h1>
104      <p>Automatyczny system wdrozen NOVA LAB</p>
105    </div>
106
107    <div class="pipeline-status" :class="pipelineStatus">
108      <div class="status-indicator">
109        <template v-if="pipelineStatus === 'idle'">Bezczynny</template>
110        <template v-else-if="pipelineStatus === 'running'">W toku</template>
111        <template v-else-if="pipelineStatus === 'success'">Sukces</template>
112        <template v-else-if="pipelineStatus === 'failed'">Blad</template>
113      </div>
114      <div class="overall-progress">
115        Postep: {{ 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>Logi pipeline</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        ▸ Uruchom 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>

Widzisz błąd w tej lekcji?

Sprawdź się

Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.

  1. 1. Jak wdrożyć aplikację Vue na platformę Vercel?

  2. 2. Jakie ustawienia build są potrzebne przy deployment na Netlify?

To 2 z 4 pytań do tej lekcji. Pozostałe rozwiążesz w grze.

Zadania praktyczne w grze

  • Edytor kodu

    Uzupełnij plik vercel.json z konfiguracją buildCommand, outputDirectory, framework i rewrites dla SPA.

  • Układanie w pionie

    Uporządkuj etapy pipeline CI/CD od pierwszego do ostatniego:

  • Układanie w pionie

    Ułóż etapy pipeline CI/CD od kodu do produkcji:

  • Klikanie w kolejności

    Ułóż komendę budowania obrazu Docker:

  • Układanie w poziomie

    Ułóż konfigurację Vite dla GitHub Pages:

  • Edytor kodu

    Uzupełnij Dockerfile z multi-stage build: etap budowania (node) i etap produkcyjny (nginx).

Przydatne artykuły