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 initif 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 theapp/folder as the entry point (since Nuxt 4 stable). If your project is on early Nuxt 3.x, the root structure may usepages/,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-imports —
components/,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 innuxt buildisnode-server. To deploy to other platforms (Vercel, Cloudflare, Netlify), changeNITRO_PRESETor set it innuxt.config.tswithnitro.preset. For local development with Docker,node-serveris 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.nuxtfolder 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].ts — posts/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' } },
},
});
| Rule | Mode | Best For |
|---|---|---|
prerender: true | SSG | Pages that rarely change |
isr: 60 | ISR | Pages cached for 60 seconds |
ssr: true | SSR | Dynamic, user-specific content |
ssr: false | SPA | Dashboards, auth-required pages |
For pure SSG projects (all pages static), addnitro: { preset: 'static' }innuxt.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
pnpmspeed up installs. Thedepsstage installs dependencies,builderbuilds withnuxt build,runnerserves.output/server/index.mjs.- The Nitro preset
node-serverproduces portable output. For static-only, thestaticpreset produces an.output/public/folder served by nginx.- File-based routing in
pages/, server routes inserver/api/, auto-imports for components/composables/utils.- Hybrid rendering with
routeRuleslets different pages use different strategies (SSG for about, ISR for home, SPA for dashboards).- Anonymous volumes for
node_modules,.nuxt, and.outputprevent host conflicts.- State management with
useStatefor simple cases, Pinia for complex ones. The@pinia/nuxtmodule integrates automatically.- Healthchecks are mandatory for
dbandcachesowebstarts 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.