diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f9fc47d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,29 @@ +# dependencies — installed inside the image +node_modules + +# build output — produced by the builder stage +dist + +# version control & editor +.git +.gitignore +.vscode +.idea + +# docker artifacts +Dockerfile +.dockerignore + +# environment & local config +.env +.env.* +*.local + +# caches & logs +.eslintcache +*.log +tmp + +# repo tooling not needed inside the image +scripts +*.md diff --git a/Dockerfile b/Dockerfile index c668429..c1b23b3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,34 +1,53 @@ # ── Mozimo — TanStack Start (Vite) ────────────────────────── -FROM node:lts-trixie-slim AS deps -# RUN apk add --no-cache libc6-compat -WORKDIR /app -COPY package.json yarn.lock* package-lock.json* ./ -RUN \ - if [ -f yarn.lock ]; then yarn --frozen-lockfile; \ - elif [ -f package-lock.json ]; then npm ci; \ - else echo "Lockfile not found." && exit 1; \ - fi +# Three-stage build: +# deps — reproducible dependency install (npm ci, cache-mounted) +# builder — production build (vite build → dist/) +# runner — lean runtime: production deps + dist + a plain Node server +# (no build tooling ships in the final image) -FROM node:lts-trixie-slim AS builder +ARG NODE_VERSION=24 + +# ── deps ──────────────────────────────────────────────────── +FROM node:${NODE_VERSION}-trixie-slim AS deps +WORKDIR /app +COPY package.json package-lock.json ./ +RUN --mount=type=cache,target=/root/.npm \ + npm ci --no-audit --no-fund + +# ── builder ───────────────────────────────────────────────── +FROM node:${NODE_VERSION}-trixie-slim AS builder WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . -RUN \ - if [ -f yarn.lock ]; then yarn run build; \ - elif [ -f package-lock.json ]; then npm run build; \ - else echo "Lockfile not found." && exit 1; \ - fi +RUN npm run build -FROM node:lts-trixie-slim AS runner +# ── runner ────────────────────────────────────────────────── +FROM node:${NODE_VERSION}-trixie-slim AS runner +LABEL org.opencontainers.image.title="Mozimo" \ + org.opencontainers.image.description="Mozimo — luxury chocolate storefront (TanStack Start + Vite)" WORKDIR /app -ENV NODE_ENV=production -ENV PORT=3000 -ENV HOST=0.0.0.0 -COPY --from=builder /app/node_modules ./node_modules -COPY --from=builder /app/dist ./dist -COPY --from=builder /app/public ./public -COPY --from=builder /app/package.json ./package.json -COPY --from=builder /app/vite.config.ts ./vite.config.ts -COPY --from=builder /app/tsconfig.json ./tsconfig.json + +ENV NODE_ENV=production \ + HOST=0.0.0.0 \ + PORT=3000 + +# Install production dependencies only (react + @tanstack/* — no vite, +# tailwind, eslint or typescript ship in the runtime image). +# The cache mount never lands in image layers, so no cache cleaning needed. +COPY package.json package-lock.json ./ +RUN --mount=type=cache,target=/root/.npm \ + npm ci --omit=dev --no-audit --no-fund + +COPY --from=builder --chown=root:root /app/dist ./dist +COPY --chmod=0644 server.mjs ./ + +# Run as the unprivileged `node` user shipped with the base image. +USER node + EXPOSE 3000 -CMD ["npm", "start"] + +HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ + CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||3000)+'/',{redirect:'manual'}).then(r=>process.exit(r.status<500?0:1)).catch(()=>process.exit(1))" + +# Exec form: node runs as PID 1 and server.mjs handles SIGTERM gracefully. +CMD ["node", "server.mjs"] diff --git a/README.md b/README.md index 59a27c4..7f8d63b 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,21 @@ collection links point there. All product photography is self-hosted under ```bash npm run dev # vite dev server (port 3000) npm run build # production build to dist/ -npm run start # serve the production build (vite preview) +npm start # serve the production build (node server.mjs) +npm run preview # serve the production build via vite preview npm run lint # eslint ``` + +## Docker + +```bash +docker build -t mozimo-web . +docker run -p 3000:3000 mozimo-web +``` + +Three-stage build (`deps → builder → runner`) on pinned `node:24-trixie-slim`: +dependencies install with `npm ci` and BuildKit cache mounts, the final image +runs as the unprivileged `node` user, ships only production dependencies plus +the build output and a small Node server (`server.mjs`: static assets with +immutable caching + gzip, SSR fetch-handler fallback, graceful shutdown), and +includes a `HEALTHCHECK`. `.dockerignore` keeps build context minimal. diff --git a/package-lock.json b/package-lock.json index 69f8fc9..5d99438 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,21 +1,17 @@ { "name": "mozimo", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mozimo", - "version": "0.1.0", + "version": "0.2.0", "dependencies": { "@tanstack/react-router": "^1.168.3", "@tanstack/react-start": "^1.167.5", - "@unpic/react": "^1.0.2", - "framer-motion": "^12.23.9", - "lucide-react": "^1.0.1", "react": "^19.1.0", - "react-dom": "^19.1.0", - "unpic": "^4.2.2" + "react-dom": "^19.1.0" }, "devDependencies": { "@eslint/eslintrc": "^3", @@ -32,7 +28,7 @@ "vite": "^8.0.2" }, "engines": { - "node": ">=18.18.0" + "node": ">=24.0.0" } }, "node_modules/@babel/code-frame": { @@ -3159,32 +3155,6 @@ "@types/react": "^19.2.0" } }, - "node_modules/@unpic/core": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@unpic/core/-/core-1.0.3.tgz", - "integrity": "sha512-aum9YNVUGso7MjGLD0Rp/08kywCGLqZ03/q6VQBFFakDBOXWEc8D4kPGcZ8v5wEnGRex3lE+++bOuucBp3KJ/w==", - "dependencies": { - "unpic": "^4.2.2" - } - }, - "node_modules/@unpic/react": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@unpic/react/-/react-1.0.2.tgz", - "integrity": "sha512-5RmRfELwTF8w+4zjtQGqjpvX+RU2VLvis3xDCS1O2uWk0PZN2cvatL+3/KAR3mshAuRrkFGTX1XwyAezSXaoCA==", - "dependencies": { - "@unpic/core": "^1.0.3" - }, - "peerDependencies": { - "next": "^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0", - "react": "^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "next": { - "optional": true - } - } - }, "node_modules/@vitejs/plugin-react": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", @@ -4123,32 +4093,6 @@ "url": "https://github.com/sponsors/rawify" } }, - "node_modules/framer-motion": { - "version": "12.43.0", - "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.43.0.tgz", - "integrity": "sha512-1eaL3RvR/kAlbG7UYcpMptEyzPoENO0c6w7ZnB3/hh2vSAz/6uGAFn6fdoqTBguNstf3MsFhJHsD/0DHiclG+g==", - "dependencies": { - "motion-dom": "^12.43.0", - "motion-utils": "^12.39.0", - "tslib": "^2.4.0" - }, - "peerDependencies": { - "@emotion/is-prop-valid": "*", - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@emotion/is-prop-valid": { - "optional": true - }, - "react": { - "optional": true - }, - "react-dom": { - "optional": true - } - } - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -4718,14 +4662,6 @@ "yallist": "^3.0.2" } }, - "node_modules/lucide-react": { - "version": "1.39.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.39.0.tgz", - "integrity": "sha512-y8nXoEwvqqIsF927NBWXODa4bfMrcUeEb/9sgpwFqg0gUjgn3j5Hznk+v7STmPgZ2iQ11JKlbQGdFRuTOwvYkA==", - "peerDependencies": { - "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -4752,19 +4688,6 @@ "node": "*" } }, - "node_modules/motion-dom": { - "version": "12.43.0", - "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.43.0.tgz", - "integrity": "sha512-azKON4d9S65PEoFUiQTMTgPheEmzf2QngdRc50AKfJp9Q9mmcBVw22c8eMq9k8kxOFHdL7+WZY7N/5F/lwiDag==", - "dependencies": { - "motion-utils": "^12.39.0" - } - }, - "node_modules/motion-utils": { - "version": "12.39.0", - "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.39.0.tgz", - "integrity": "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==" - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -5368,7 +5291,8 @@ "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true }, "node_modules/type-check": { "version": "0.4.0", @@ -5446,11 +5370,6 @@ "node": ">=4" } }, - "node_modules/unpic": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/unpic/-/unpic-4.2.2.tgz", - "integrity": "sha512-z6T2ScMgRV2y2H8MwwhY5xHZWXhUx/YxtOCGJwfURSl7ypVy4HpLIMWoIZKnnxQa/RKzM0kg8hUh0paIrpLfvw==" - }, "node_modules/unplugin": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-3.3.0.tgz", diff --git a/package.json b/package.json index 95db4bc..fbc680c 100644 --- a/package.json +++ b/package.json @@ -9,18 +9,15 @@ "scripts": { "dev": "vite dev", "build": "vite build", - "start": "vite preview --host 0.0.0.0", + "start": "node server.mjs", + "preview": "vite preview --host 0.0.0.0", "lint": "eslint ." }, "dependencies": { "@tanstack/react-router": "^1.168.3", "@tanstack/react-start": "^1.167.5", - "@unpic/react": "^1.0.2", - "framer-motion": "^12.23.9", - "lucide-react": "^1.0.1", "react": "^19.1.0", - "react-dom": "^19.1.0", - "unpic": "^4.2.2" + "react-dom": "^19.1.0" }, "devDependencies": { "@eslint/eslintrc": "^3", @@ -36,4 +33,4 @@ "typescript": "^6.0.2", "vite": "^8.0.2" } -} \ No newline at end of file +}