Vue 3 #

Vue 3 is a progressive framework combining granular reactivity, the Composition API, and modern tooling (Vite). For small-to-mid-scale applications, Vue 3 + Vite + Pinia is an ergonomic, lightweight stack. For local development, Docker containers ensure every developer has the same Node version, isolated native dependencies, and instant onboarding.

This article covers a Vue 3 + Docker Compose setup for local development, including Vite + HMR, the Composition API, Pinia state management, Vue Router, and the production image with nginx.

Prerequisites #

Make sure you have installed:

  • Docker and Docker Compose (latest versions)
  • Node.js 20+ (optional, for tooling)

A standard Vue 3 project (bootstrapped with npm create vue@latest):

my-vue-app/
├── src/
│   ├── main.ts
│   ├── App.vue
│   ├── components/
│   ├── composables/
│   ├── views/
│   ├── stores/
│   ├── router/
│   └── assets/
├── public/
├── index.html
├── package.json
├── package-lock.json
├── vite.config.ts
├── tsconfig.json
├── Dockerfile
└── docker-compose.yml

Why Vite for Vue 3 #

Vite is the official Vue 3 bundler (since Vue 3.0). Create Vue App (Vue CLI) is deprecated. Vite provides:

  • A super-fast dev server — startup < 500ms
  • Instant HMR — CSS/JS changes update without refreshes
  • Optimal builds — Rollup produces small bundles
  • TypeScript first — built-in TypeScript configuration

Multi-Stage Dockerfile #

Vue 3 + Vite produces a static build (HTML + JS + CSS). The production image only needs nginx.

# syntax=docker/dockerfile:1.6

# ---- Stage 1: Install dependencies ----
FROM node:20-alpine AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app

COPY package.json package-lock.json* ./
RUN npm ci

# ---- Stage 2: Build Vue ----
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .

ENV NODE_ENV=production
RUN npm run build

# ---- Stage 3: Production with nginx ----
FROM nginx:1.27-alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

vite.config.ts:

import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';

export default defineConfig({
  plugins: [vue()],
  server: {
    port: 5173,
    host: '0.0.0.0', // Required for Docker
  },
  build: {
    outDir: 'dist',
    sourcemap: true,
    rollupOptions: {
      output: {
        manualChunks: {
          'vendor': ['vue', 'vue-router', 'pinia'],
        },
      },
    },
  },
});

nginx.conf:

server {
    listen 80;
    server_name _;
    root /usr/share/nginx/html;
    index index.html;

    # Cache the vendor bundle
    location /assets/ {
        expires 1y;
        add_header Cache-Control "public, immutable";
    }

    # SPA fallback
    location / {
        try_files $uri $uri/ /index.html;
    }

    # Proxy the API
    location /api/ {
        proxy_pass http://api:3001/;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

docker-compose.yml for Development #

# docker-compose.yml
services:
  web:
    build:
      context: .
      dockerfile: Dockerfile.dev
    image: my-vue-app:dev
    container_name: vue-dev
    command: npm run dev
    ports:
      - "5173:5173"
    volumes:
      - ./:/app
      - /app/node_modules
    environment:
      - NODE_ENV=development
      - VITE_API_URL=http://localhost:3001
    depends_on:
      - api

  api:
    build: ./api
    image: my-api:dev
    container_name: vue-api
    ports:
      - "3001:3001"
    environment:
      - DATABASE_URL=postgresql://app:pass@db:5432/myapp
    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:16-alpine
    environment:
      - POSTGRES_USER=app
      - POSTGRES_PASSWORD=dev
      - POSTGRES_DB=myapp
    volumes:
      - db-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d myapp"]
      interval: 10s
      timeout: 5s
      retries: 5

volumes:
  db-data:

Dockerfile.dev:

FROM node:20-alpine
RUN apk add --no-cache libc6-compat
WORKDIR /app

COPY package.json package-lock.json* ./
RUN npm install

COPY . .

EXPOSE 5173

CMD ["npm", "run", "dev"]

The Composition API #

The Composition API is the modern way to write Vue 3. More flexible than the Options API for complex logic.

<!-- src/components/Counter.vue -->
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';

const count = ref(0);
const doubleCount = computed(() => count.value * 2);

function increment() {
  count.value++;
}

onMounted(() => {
  console.log('Counter mounted, initial value:', count.value);
});
</script>

<template>
  <div class="counter">
    <p>Count: {{ count }} (double: {{ doubleCount }})</p>
    <button @click="increment">Add</button>
  </div>
</template>

<style scoped>
.counter {
  padding: 1rem;
  border: 1px solid #ccc;
}
</style>

Props with TypeScript:

<!-- src/components/UserCard.vue -->
<script setup lang="ts">
interface User {
  id: number;
  name: string;
  email: string;
}

interface Props {
  user: User;
  showEmail?: boolean;
}

const props = withDefaults(defineProps<Props>(), {
  showEmail: true,
});

const emit = defineEmits<{
  (e: 'delete', id: number): void;
}>();

function handleDelete() {
  emit('delete', props.user.id);
}
</script>

<template>
  <div class="user-card">
    <h3>{{ user.name }}</h3>
    <p v-if="showEmail">{{ user.email }}</p>
    <button @click="handleDelete">Delete</button>
  </div>
</template>

Composables for Reusable Logic #

Composables are functions using the Composition API. Used to extract logic shared across many components.

// src/composables/useLocalStorage.ts
import { ref, watch, type Ref } from 'vue';

export function useLocalStorage<T>(key: string, initialValue: T): Ref<T> {
  const stored = localStorage.getItem(key);
  const value = ref<T>(stored ? JSON.parse(stored) : initialValue);
  
  watch(value, (newValue) => {
    localStorage.setItem(key, JSON.stringify(newValue));
  });
  
  return value;
}
// src/composables/useFetch.ts
import { ref, watchEffect, type Ref } from 'vue';

export function useFetch<T>(url: Ref<string> | string) {
  const data = ref<T | null>(null);
  const loading = ref(true);
  const error = ref<Error | null>(null);
  
  watchEffect(async () => {
    loading.value = true;
    try {
      const response = await fetch(typeof url === 'string' ? url : url.value);
      data.value = await response.json();
      error.value = null;
    } catch (e) {
      error.value = e as Error;
    } finally {
      loading.value = false;
    }
  });
  
  return { data, loading, error };
}

Use it in a component:

<script setup lang="ts">
import { ref } from 'vue';
import { useLocalStorage } from '@/composables/useLocalStorage';
import { useFetch } from '@/composables/useFetch';

const theme = useLocalStorage<string>('theme', 'light');
const apiUrl = ref('/api/users');
const { data, loading, error } = useFetch<User[]>(apiUrl);
</script>

<template>
  <div :class="theme">
    <p v-if="loading">Loading...</p>
    <p v-else-if="error">Error: {{ error.message }}</p>
    <ul v-else-if="data">
      <li v-for="user in data" :key="user.id">{{ user.name }}</li>
    </ul>
  </div>
</template>

State Management with Pinia #

Pinia is Vue 3’s official state management. It replaces Vuex with a simpler, TypeScript-first API.

npm install pinia
// src/main.ts
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';
import router from './router';

const app = createApp(App);
app.use(createPinia());
app.use(router);
app.mount('#app');

Setup store (recommended):

// src/stores/auth.ts
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';

interface User {
  id: number;
  name: string;
  email: string;
}

export const useAuthStore = defineStore('auth', () => {
  const user = ref<User | null>(null);
  const token = ref<string | null>(null);
  
  const isAuthenticated = computed(() => user.value !== null);
  
  async function login(email: string, password: string) {
    const res = await fetch('/api/auth/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email, password }),
    });
    const data = await res.json();
    user.value = data.user;
    token.value = data.token;
  }
  
  function logout() {
    user.value = null;
    token.value = null;
  }
  
  return { user, token, isAuthenticated, login, logout };
});

Options store (alternative):

// src/stores/cart.ts
import { defineStore } from 'pinia';

interface CartItem {
  id: number;
  name: string;
  price: number;
  qty: number;
}

interface CartState {
  items: CartItem[];
}

export const useCartStore = defineStore('cart', {
  state: (): CartState => ({ items: [] }),
  getters: {
    total: (state) => state.items.reduce((sum, i) => sum + i.price * i.qty, 0),
    itemCount: (state) => state.items.reduce((sum, i) => sum + i.qty, 0),
  },
  actions: {
    add(item: Omit<CartItem, 'qty'>) {
      const existing = this.items.find(i => i.id === item.id);
      if (existing) {
        existing.qty++;
      } else {
        this.items.push({ ...item, qty: 1 });
      }
    },
    remove(id: number) {
      this.items = this.items.filter(i => i.id !== id);
    },
    clear() {
      this.items = [];
    },
  },
});

Use it in a component:

<script setup lang="ts">
import { useAuthStore } from '@/stores/auth';
import { useCartStore } from '@/stores/cart';
import { storeToRefs } from 'pinia';

const auth = useAuthStore();
const cart = useCartStore();

// storeToRefs for reactivity on state/getters
const { user, isAuthenticated } = storeToRefs(auth);
const { total, itemCount } = storeToRefs(cart);

// Actions can be called directly
const { login, logout } = auth;
const { add, remove } = cart;
</script>

<template>
  <div>
    <div v-if="isAuthenticated">
      <p>Hello, {{ user.name }}!</p>
      <button @click="logout">Logout</button>
    </div>
    <div v-else>
      <button @click="login('[email protected]', 'pass')">Login</button>
    </div>
    
    <p>Cart: {{ itemCount }} items  ${{ total.toLocaleString('en-US') }}</p>
    <button @click="add({ id: 1, name: 'Product', price: 50000 })">
      Add to Cart
    </button>
  </div>
</template>
Use storeToRefs for state and getters — it preserves reactivity when destructuring. For actions, direct destructuring is fine.

Vue Router #

npm install vue-router@4
// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router';

const router = createRouter({
  history: createWebHistory(),
  routes: [
    {
      path: '/',
      name: 'home',
      component: () => import('@/views/HomeView.vue'),
    },
    {
      path: '/products',
      name: 'products',
      component: () => import('@/views/ProductsView.vue'),
    },
    {
      path: '/products/:id',
      name: 'product-detail',
      component: () => import('@/views/ProductDetailView.vue'),
      props: true,
    },
    {
      path: '/:pathMatch(.*)*',
      name: 'not-found',
      component: () => import('@/views/NotFoundView.vue'),
    },
  ],
});

export default router;

Use it in App.vue:

<script setup lang="ts">
import { RouterLink, RouterView } from 'vue-router';
</script>

<template>
  <header>
    <nav>
      <RouterLink to="/">Home</RouterLink>
      <RouterLink to="/products">Products</RouterLink>
    </nav>
  </header>
  <main>
    <RouterView />
  </main>
</template>

Build and Run #

# Development
docker compose up --build
# Access: http://localhost:5173

# Production build
docker build -t my-vue-app:prod -f Dockerfile .
docker run -p 8080:80 my-vue-app:prod

# View logs
docker compose logs -f web

# Stop
docker compose down

Best Practices #

1. Separate Dev and Prod Dockerfiles #

Dockerfile.dev for the Vite dev server, a multi-stage Dockerfile for production nginx. Dev mode needs the full node_modules.

2. Vite host: '0.0.0.0' #

Mandatory in vite.config.ts. Without it, Vite only listens to localhost inside the container.

3. Use <script setup> SFCs #

<script setup> is more concise than the regular Composition API. Type inference works better, and defineProps/defineEmits become macros.

4. Use storeToRefs for Destructuring #

// ✗ Loses reactivity
const { user } = useAuthStore();

// ✓ Reactive
const { user } = storeToRefs(useAuthStore());

5. Lazy-Load Route Components #

component: () => import('@/views/ProductsView.vue')

Vite automatically code-splits per route, speeding up the initial load.

6. Composables for Reusable Logic #

Don’t duplicate logic across many components. Extract it into composables/.

7. TypeScript for Type Safety #

Vue 3 + TypeScript = the best DX. Define interfaces for props, emits, and store state.

8. State vs Composables vs Pinia #

LocationUse For
Local ref/reactiveState used by 1 component only
provide/injectState used by a subtree (form sections)
ComposablesLogic used across many components
PiniaGlobal state (auth, cart, user preferences)

9. Pinia vs Vuex #

Vuex is deprecated for Vue 3. Use Pinia.

10. The Vite Plugin @vitejs/plugin-vue #

The official plugin for compiling SFCs. Don’t use other compilers for .vue files.


Troubleshooting #

HMR Not Updating #

Make sure host: '0.0.0.0' and the source bind mount are active.

“Failed to resolve component” #

Usually auto-import isn’t enabled or the component name is wrong. Check the components option in vite.config.ts or import manually.

Pinia Store Not Reactive #

Make sure to use storeToRefs when destructuring state/getters.

Build Fails with “Rollup parse error” #

Check the plugin order in vite.config.ts. @vitejs/plugin-vue must come before other plugins processing .vue files.

Port 5173 Conflict #

Change the port mapping in Compose or stop the process:

lsof -i :5173

Summary #

  • Vue 3 is ideal for SPAs with Vite + Pinia. A slim production image (~30MB nginx + static).
  • Multi-stage Dockerfiles with static nginx serving. The deps stage installs, builder builds with Vite, runner serves dist/.
  • Vite HMR is based on native ESM — instant SFC changes. Make sure host: '0.0.0.0'.
  • The Composition API with <script setup> is the modern Vue 3 standard. More concise, better type inference.
  • Composables in src/composables/ extract reusable logic. The useXxx naming convention.
  • Pinia is Vue 3’s official state management. Setup stores or options stores. Use storeToRefs for reactive destructuring.
  • Vue Router 4 with lazy-loaded routes for automatic code splitting. Use RouterView at the root.
  • Anonymous volumes for node_modules prevent native binary conflicts with the host.
  • Environment variables — only VITE_* enters the bundle. Secrets belong in the backend.
  • Dev Dockerfiles are simple, prod Dockerfiles are multi-stage. Don’t mix them.
  • Lazy-load routes with () => import('@/views/...') to speed up initial loads.
  • Choose state management by scale: local (ref), composables, Pinia for global.
  • Troubleshooting: dead HMR (check host), component errors (check auto-import), Pinia reactivity (storeToRefs), port conflicts.
  • The Vite plugin @vitejs/plugin-vue is mandatory. Optimal TypeScript inference with vue-tsc for type checking.
  • TypeScript for all components, stores, and composables. Interfaces for props, emits, and state.

← Previous: React   Next: Svelte →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact