Nuxt 3 #

Nuxt 3 is a Vue meta-framework delivering complete opinions: automatic file-based routing, server routes, auto-imports, and hybrid rendering (a mix of SSR, SSG, ISR, and CSR in one project). Its rendering engine, Nitro, produces universal output that can run on Node, Bun, Workers, or as a static site. Because of this complexity, Docker for local development must separate the dev mode (Vite + Nitro dev) from production (the Nitro Node preset).

This article covers a Nuxt 3 + Docker Compose setup for local development, complete with multi-stage Dockerfiles, Postgres/Redis integration, Nitro preset strategies, and best practices for hybrid rendering.

Prerequisites #

Make sure you have installed:

  • Docker and Docker Compose (latest versions)
  • Node.js 20+ (optional, for host tooling)
  • pnpm or npm (for nuxi init if scaffolding manually)

A standard Nuxt 3 project (bootstrapped with npx nuxi@latest init):

my-nuxt-app/
├── app/
│   ├── app.vue
│   ├── pages/             # File-based routing
│   ├── components/        # Auto-imported
│   ├── composables/       # Auto-imported
│   ├── layouts/
│   ├── middleware/
│   └── assets/
├── server/
│   ├── api/               # Server routes (/api/...)
│   ├── routes/            # Server middleware
│   └── utils/
├── public/
├── nuxt.config.ts
├── package.json
├── Dockerfile
├── docker-compose.yml
└── .env
Unlike Nuxt 2, Nuxt 3 uses the app/ folder as the entry point (since Nuxt 4 stable). If your project is on early Nuxt 3.x, the root structure may use pages/, components/, etc. directly at the root.

Nuxt 3 Architecture #

Before writing the Dockerfile, understand Nuxt 3’s internal architecture:

flowchart TB
    Browser[Browser]
    NuxtServer[Nuxt Server / Nitro]
    VueApp[Vue 3 App]
    Vite[Vite Dev Server]
    API[Server Routes /api]
    Storage[Storage Layer]
    DB[(Database)]
    Cache[(Redis)]
    
    Browser <-->|HTTP/WS| NuxtServer
    NuxtServer --> VueApp
    Vite -.->|HMR| Browser
    NuxtServer --> API
    API --> Storage
    Storage --> DB
    Storage --> Cache
  • Nitro — the universal server engine, producing output for many runtimes
  • Vite — the bundler and dev server with HMR
  • Vue 3 — the reactivity engine with the Composition API
  • Server Routes — automatic API endpoints in the server/api/ folder
  • Auto-importscomponents/, composables/, utils/ are imported automatically

Multi-Stage Dockerfile #

# syntax=docker/dockerfile:1.6

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

# Use pnpm for fast, deterministic installs
COPY package.json pnpm-lock.yaml* ./
RUN corepack enable && pnpm install --frozen-lockfile

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

ENV NITRO_PRESET=node-server
ENV NODE_ENV=production

# Disable telemetry during builds
ENV NUXT_TELEMETRY_DISABLED=1

RUN corepack enable && pnpm build

# ---- Stage 3: Production runner ----
FROM node:20-alpine AS runner
WORKDIR /app

ENV NODE_ENV=production
ENV NUXT_TELEMETRY_DISABLED=1
ENV HOST=0.0.0.0
ENV PORT=3000

# Create a non-root user
RUN addgroup --system --gid 1001 nuxt
RUN adduser --system --uid 1001 nuxt

# Copy the Nitro output
COPY --from=builder --chown=nuxt:nuxt /app/.output ./.output

USER nuxt
EXPOSE 3000

CMD ["node", ".output/server/index.mjs"]

The builder stage produces the .output/ folder containing the Node server + client assets. This folder is portable and can run anywhere with Node 20+.

The default Nitro preset in nuxt build is node-server. To deploy to other platforms (Vercel, Cloudflare, Netlify), change NITRO_PRESET or set it in nuxt.config.ts with nitro.preset. For local development with Docker, node-server is the most straightforward choice.

nuxt.config.ts for Production #

// nuxt.config.ts
export default defineNuxtConfig({
  compatibilityDate: '2025-01-01',
  devtools: { enabled: true },
  
  // SSR enabled for hybrid rendering
  ssr: true,
  
  nitro: {
    preset: 'node-server',
    storage: {
      // Redis storage adapter
      cache: {
        driver: 'redis',
        url: process.env.REDIS_URL || 'redis://localhost:6379',
      },
    },
  },
  
  runtimeConfig: {
    databaseUrl: process.env.DATABASE_URL,
    redisUrl: process.env.REDIS_URL,
    public: {
      apiBase: process.env.NUXT_PUBLIC_API_BASE || '/api',
    },
  },
});

docker-compose.yml for Development #

# docker-compose.yml
services:
  web:
    build:
      context: .
      dockerfile: Dockerfile.dev
    image: my-nuxt-app:dev
    container_name: nuxt-dev
    command: npm run dev -- --host 0.0.0.0
    ports:
      - "3000:3000"
    volumes:
      - ./:/app
      - /app/node_modules
      - /app/.nuxt
      - /app/.output
    environment:
      - NODE_ENV=development
      - NUXT_TELEMETRY_DISABLED=1
      - DATABASE_URL=postgresql://app:pass@db:5432/myapp
      - REDIS_URL=redis://cache:6379
    depends_on:
      db:
        condition: service_healthy
      cache:
        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
    ports:
      - "5432:5432"

  cache:
    image: redis:7-alpine
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 3s
      retries: 3
    ports:
      - "6379:6379"

volumes:
  db-data:

Dockerfile.dev:

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

# Enable pnpm via corepack
RUN corepack enable

COPY package.json pnpm-lock.yaml* ./
RUN pnpm install

COPY . .

EXPOSE 3000

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

Service Explanations #

web — the Nuxt dev server service. Uses the simple Dockerfile.dev. Anonymous volumes for node_modules, .nuxt (build cache), and .output (Nitro output) prevent host conflicts.

db and cache — Postgres and Redis as dependencies, with healthchecks.

Why an anonymous volume for .nuxt? The .nuxt folder contains TypeScript types generated by Nuxt. If mounted from the host, type generation often conflicts with the version generated by the container. With an anonymous volume, the container always generates its own .nuxt, consistent with the Node and package versions in use.

File-Based Routing #

Nuxt 3 automatically creates routes from the folder structure in pages/:

pages/
├── index.vue            # /
├── about.vue            # /about
├── blog/
│   ├── index.vue        # /blog
│   └── [slug].vue       # /blog/:slug (dynamic)
├── products/
│   ├── [id].vue         # /products/:id
│   └── [[category]].vue # /products/:category? (optional)
└── [...notFound].vue    # Catch-all 404

Example page component:

<!-- pages/blog/[slug].vue -->
<script setup lang="ts">
const route = useRoute();
const { data: post } = await useFetch(`/api/posts/${route.params.slug}`);
</script>

<template>
  <article v-if="post">
    <h1>{{ post.title }}</h1>
    <p>{{ post.content }}</p>
  </article>
</template>

Dynamic route with validation:

<!-- pages/products/[id].vue -->
<script setup lang="ts">
const route = useRoute();
const id = computed(() => Number(route.params.id));

// Validation
if (isNaN(id.value)) {
  throw createError({ statusCode: 400, statusMessage: 'Invalid ID' });
}

const { data: product } = await useFetch(`/api/products/${id.value}`);
</script>

<template>
  <div v-if="product">
    <h1>{{ product.name }}</h1>
    <p>${{ product.price.toLocaleString('en-US') }}</p>
  </div>
</template>

Server Routes #

The server/api/ folder automatically becomes API endpoints:

// server/api/posts/index.get.ts
import { db } from '~/server/utils/db';

export default defineEventHandler(async () => {
  const posts = await db.post.findMany();
  return posts;
});
// server/api/posts/[id].get.ts
export default defineEventHandler(async (event) => {
  const id = getRouterParam(event, 'id');
  const post = await db.post.findUnique({ where: { id: Number(id) } });
  if (!post) {
    throw createError({ statusCode: 404, statusMessage: 'Post not found' });
  }
  return post;
});
// server/api/posts/index.post.ts
export default defineEventHandler(async (event) => {
  const body = await readBody(event);
  const post = await db.post.create({ data: body });
  setResponseStatus(event, 201);
  return post;
});

The file naming convention: [name].[method].tsposts/index.get.ts for GET /api/posts, posts/index.post.ts for POST /api/posts.


Composables and Auto-Imports #

One of Nuxt 3’s strengths is auto-imports. Components, composables, and utilities don’t need manual imports:

// composables/useAuth.ts — auto-imported as useAuth()
export function useAuth() {
  const user = useState<User | null>('user', () => null);
  
  async function login(email: string, password: string) {
    const result = await $fetch('/api/auth/login', {
      method: 'POST',
      body: { email, password },
    });
    user.value = result.user;
  }
  
  function logout() {
    user.value = null;
  }
  
  return { user, login, logout };
}

Use it in a component without imports:

<script setup lang="ts">
const { user, login, logout } = useAuth();
</script>

<template>
  <div v-if="user">
    <p>Hello, {{ user.name }}!</p>
    <button @click="logout">Logout</button>
  </div>
  <button v-else @click="login('[email protected]', 'pass')">Login</button>
</template>

State Management with useState and Pinia #

Nuxt 3 has useState for shared cross-component state, or use Pinia for more structured state management.

useState for simple state:

// composables/useCounter.ts
export const useCounter = () => useState<number>('counter', () => 0);

Pinia for complex state:

npm install @pinia/nuxt pinia
// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@pinia/nuxt'],
});
// stores/cart.ts
import { defineStore } from 'pinia';

export const useCartStore = defineStore('cart', {
  state: () => ({
    items: [] as Array<{ id: number; name: string; price: number; qty: number }>,
  }),
  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: { id: number; name: string; price: number }) {
      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">
const cart = useCartStore();
</script>

<template>
  <div>
    <p>Items: {{ cart.itemCount }}  Total: ${{ cart.total.toLocaleString('en-US') }}</p>
    <button @click="cart.clear">Clear</button>
  </div>
</template>

Hybrid Rendering #

One of Nuxt 3’s most powerful features: you can mix SSR, SSG, ISR, and SPA in one project, just with routeRules:

// nuxt.config.ts
export default defineNuxtConfig({
  routeRules: {
    // Homepage: ISR, revalidate every 60 seconds
    '/': { isr: 60 },
    
    // Static pages: SSG
    '/about': { prerender: true },
    '/blog/**': { prerender: true },
    
    // API: SSR (default)
    '/api/**': { ssr: true },
    
    // Dashboard: client-side only
    '/dashboard/**': { ssr: false },
    
    // Product detail: SSR with caching
    '/products/**': { ssr: true, headers: { 'cache-control': 's-maxage=60' } },
  },
});
RuleModeBest For
prerender: trueSSGPages that rarely change
isr: 60ISRPages cached for 60 seconds
ssr: trueSSRDynamic, user-specific content
ssr: falseSPADashboards, auth-required pages
For pure SSG projects (all pages static), add nitro: { preset: 'static' } in nuxt.config.ts. The build produces an .output/public/ folder that can be served with nginx.

Static Export with Nginx #

# syntax=docker/dockerfile:1.6
FROM node:20-alpine AS builder
WORKDIR /app
RUN corepack enable
COPY package.json pnpm-lock.yaml* ./
RUN pnpm install --frozen-lockfile
COPY . .
ENV NUXT_TELEMETRY_DISABLED=1
RUN pnpm generate  # produces .output/public/

FROM nginx:1.27-alpine
COPY --from=builder /app/.output/public /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

nuxt generate (or nuxi generate) prerenders all pages marked SSG/ISR. The output is static HTML + assets.


Build and Run #

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

# Production build
docker build -t my-nuxt-app:prod -f Dockerfile .
docker run -p 3000:3000 my-nuxt-app:prod

# View logs
docker compose logs -f web

# Stop
docker compose down

Best Practices #

1. Use pnpm for Fast Installs #

Nuxt 3 itself recommends pnpm for its content-addressable storage that speeds up installs. Enable it via corepack enable.

2. Use NITRO_PRESET=node-server in Docker #

This preset produces output that runs on any Linux container with Node 20+. Avoid platform-specific presets (Vercel, Netlify) in local development.

3. Disable Telemetry #

NUXT_TELEMETRY_DISABLED=1 in the Dockerfile and .env. Faster builds and no outbound calls.

4. Auto-Import Only What’s Universal #

Don’t put page-specific logic in composables/ if it’s only used on one page. Keep it in the component or utils/.

5. Healthchecks for Dependent Services #

Use healthchecks so web starts after db and cache are ready:

depends_on:
  db:
    condition: service_healthy
  cache:
    condition: service_healthy

6. Volumes for .nuxt and .output #

volumes:
  - /app/.nuxt
  - /app/.output

Without the volumes, type generation and Nitro output conflict with the host.

7. Dev Mode Must Have --host 0.0.0.0 #

Without this flag, the dev server only binds to localhost inside the container, unreachable from the host.


Troubleshooting #

Port 3000 Already in Use #

Change the port mapping in Compose or stop the process using the port:

lsof -i :3000

HMR Not Working #

Make sure the bind mount volume is active and the .nuxt folder is isolated. Check with docker compose exec web ls -la .nuxt.

“Cannot find module” After Adding Packages #

Restart the container with a rebuild:

docker compose up --build

Or just restart:

docker compose restart web

Server Route Returns 404 #

Check the file naming convention: server/api/[name].[method].ts. Files without a method suffix are treated as generic handlers.


Summary #

  • Nuxt 3 is ideal for hybrid rendering — SSR/SSG/ISR/SPA in one project with file-based routing.
  • Multi-stage Dockerfiles with pnpm speed up installs. The deps stage installs dependencies, builder builds with nuxt build, runner serves .output/server/index.mjs.
  • The Nitro preset node-server produces portable output. For static-only, the static preset produces an .output/public/ folder served by nginx.
  • File-based routing in pages/, server routes in server/api/, auto-imports for components/composables/utils.
  • Hybrid rendering with routeRules lets different pages use different strategies (SSG for about, ISR for home, SPA for dashboards).
  • Anonymous volumes for node_modules, .nuxt, and .output prevent host conflicts.
  • State management with useState for simple cases, Pinia for complex ones. The @pinia/nuxt module integrates automatically.
  • Healthchecks are mandatory for db and cache so web starts after dependencies are ready.
  • Dev mode uses a simple Dockerfile.dev, prod mode uses multi-stage. Don’t mix them.
  • Best practices: pnpm, NUXT_TELEMETRY_DISABLED, –host 0.0.0.0, anonymous cache volumes, healthchecks.
  • Troubleshooting: port conflicts (change mappings), dead HMR (check volumes), server route 404s (check naming conventions).
  • Static exports for fully static blogs/landing pages. Build with nuxt generate, serve with nginx.

← Previous: Next.js   Next: React →

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