diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..2bad3b1b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,50 @@ +# dependencies +node_modules +.pnp +.pnp.* + +# build output +.next +out +build +dist + +# env files (provide at runtime, not build time) +.env +.env.local +.env.*.local + +# version control +.git +.gitignore + +# editor / tooling +.claude +.conductor +.ruby-lsp +.vscode +.idea +.DS_Store + +# CI / docs / tests (not needed in image) +.github +docs +README.md +LICENSE +coverage +*.tsbuildinfo +next-env.d.ts +vitest.config.ts + +# logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# vercel +.vercel + +# docker +Dockerfile +.dockerignore diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..6449d994 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,97 @@ +# syntax=docker/dockerfile:1.7 + +# Multi-stage build for Next.js 16 (standalone output) on Node.js 22 (Alpine). +# +# Build (Spree env required: pages are prerendered against the Spree API at build time): +# docker build \ +# --build-arg SPREE_API_URL=https://your-spree.example.com \ +# --build-arg SPREE_PUBLISHABLE_KEY=your_publishable_key \ +# -t storefront . +# +# Run: +# docker run -p 3001:3001 --env-file .env.local storefront +# +# Optional Sentry source map upload at build time (skipped when SENTRY_DSN is unset). +# SENTRY_AUTH_TOKEN is read via a BuildKit secret so it never lands in image +# layers, history, or shared builder caches. +# SENTRY_AUTH_TOKEN=... docker build \ +# --build-arg SPREE_API_URL=... \ +# --build-arg SPREE_PUBLISHABLE_KEY=... \ +# --build-arg SENTRY_DSN=... \ +# --build-arg SENTRY_ORG=... \ +# --build-arg SENTRY_PROJECT=... \ +# --secret id=sentry_auth_token,env=SENTRY_AUTH_TOKEN \ +# -t storefront . + +ARG NODE_VERSION=22-alpine + + +# ---- deps: install production+dev dependencies for the build ---- +FROM node:${NODE_VERSION} AS deps +WORKDIR /app + +# libc6-compat keeps a few native modules happy on Alpine (musl). +RUN apk add --no-cache libc6-compat + +COPY package.json package-lock.json ./ +RUN --mount=type=cache,target=/root/.npm \ + npm ci --include=dev + + +# ---- builder: compile the Next.js app ---- +FROM node:${NODE_VERSION} AS builder +WORKDIR /app + +ENV NEXT_TELEMETRY_DISABLED=1 +ENV NODE_ENV=production + +# Spree API config — required at build time because the app prerenders pages +# that fetch from Spree (categories, products, etc.). +ARG SPREE_API_URL +ARG SPREE_PUBLISHABLE_KEY +ENV SPREE_API_URL=$SPREE_API_URL \ + SPREE_PUBLISHABLE_KEY=$SPREE_PUBLISHABLE_KEY + +# Optional Sentry release/source-map upload. When SENTRY_DSN is empty, +# next.config.ts skips withSentryConfig entirely, so the build still works. +# SENTRY_AUTH_TOKEN is intentionally not declared as ARG/ENV — it's mounted +# only for the build step via --mount=type=secret below, so it never persists +# in image layers or build cache. +ARG SENTRY_DSN="" +ARG SENTRY_ORG="" +ARG SENTRY_PROJECT="" +ENV SENTRY_DSN=$SENTRY_DSN \ + SENTRY_ORG=$SENTRY_ORG \ + SENTRY_PROJECT=$SENTRY_PROJECT + +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +RUN --mount=type=secret,id=sentry_auth_token,required=false \ + SENTRY_AUTH_TOKEN="$(cat /run/secrets/sentry_auth_token 2>/dev/null || true)" \ + npm run build + + +# ---- runner: minimal runtime image ---- +FROM node:${NODE_VERSION} AS runner +WORKDIR /app + +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 +ENV PORT=3001 +ENV HOSTNAME=0.0.0.0 + +RUN addgroup --system --gid 1001 nodejs \ + && adduser --system --uid 1001 nextjs + +# Static assets and the standalone server bundle. +# The standalone output ships its own minimal node_modules. +COPY --from=builder --chown=nextjs:nodejs /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +USER nextjs + +EXPOSE 3001 + +CMD ["node", "server.js"] diff --git a/README.md b/README.md index 8cca4f39..e36effc8 100644 --- a/README.md +++ b/README.md @@ -367,7 +367,9 @@ Spree Backend → Webhook POST → /api/webhooks/spree → render email → send The webhook route handler (`src/app/api/webhooks/spree/route.ts`) uses `createWebhookHandler` from `src/lib/spree/webhooks` — signature verification and event routing are handled automatically. -## Deploy on Vercel +## Deployment + +### Vercel The easiest way to deploy is using [Vercel](https://vercel.com/new): @@ -380,6 +382,59 @@ The easiest way to deploy is using [Vercel](https://vercel.com/new): - `SENTRY_DSN`, `SENTRY_ORG`, `SENTRY_PROJECT`, `SENTRY_AUTH_TOKEN` (optional — for error tracking with readable stack traces) 4. Deploy +### Docker + +A multi-stage `Dockerfile` is included at the repo root. It uses Next.js standalone output to produce a small (~240 MB) image based on `node:22-alpine`, runs as a non-root user, and exposes port `3001`. + +> **Note:** `SPREE_API_URL` and `SPREE_PUBLISHABLE_KEY` are required at **build time** because the storefront prerenders pages against the Spree API. Point them at a Spree instance reachable from wherever you run `docker build` (hosted Spree, tunnel, or `host.docker.internal` for a local backend on Docker Desktop). + +**Build:** + +```bash +docker build \ + --build-arg SPREE_API_URL=https://your-spree.example.com \ + --build-arg SPREE_PUBLISHABLE_KEY=your_publishable_key \ + -t spree-storefront . +``` + +**Run:** + +```bash +docker run -p 3001:3001 --env-file .env.local spree-storefront +``` + +**Optional — Sentry source map upload at build time:** + +`SENTRY_AUTH_TOKEN` is mounted via a BuildKit secret so it never lands in image layers or the build cache. Other Sentry vars are passed as regular build args. + +```bash +SENTRY_AUTH_TOKEN=... docker build \ + --build-arg SPREE_API_URL=... \ + --build-arg SPREE_PUBLISHABLE_KEY=... \ + --build-arg SENTRY_DSN=... \ + --build-arg SENTRY_ORG=... \ + --build-arg SENTRY_PROJECT=... \ + --secret id=sentry_auth_token,env=SENTRY_AUTH_TOKEN \ + -t spree-storefront . +``` + +**Building against a local Spree backend** (Docker Desktop on macOS/Windows): + +```bash +docker build \ + --add-host=host.docker.internal:host-gateway \ + --build-arg SPREE_API_URL=http://host.docker.internal:3000 \ + --build-arg SPREE_PUBLISHABLE_KEY=your_publishable_key \ + -t spree-storefront . + +docker run -p 3001:3001 \ + --add-host=host.docker.internal:host-gateway \ + --env-file .env.local \ + spree-storefront +``` + +The same env vars listed under [Vercel](#vercel) apply to runtime configuration. + ## License MIT diff --git a/next.config.ts b/next.config.ts index 39a20b87..028ff426 100644 --- a/next.config.ts +++ b/next.config.ts @@ -5,6 +5,7 @@ import createNextIntlPlugin from "next-intl/plugin"; const withNextIntl = createNextIntlPlugin(); const nextConfig: NextConfig = { + output: "standalone", allowedDevOrigins: ["shop.lvh.me", "*.trycloudflare.com", "192.168.33.13"], env: { NEXT_PUBLIC_SENTRY_DSN: process.env.SENTRY_DSN || "", diff --git a/src/components/layout/CurrentYear.tsx b/src/components/layout/CurrentYear.tsx new file mode 100644 index 00000000..4420449f --- /dev/null +++ b/src/components/layout/CurrentYear.tsx @@ -0,0 +1,5 @@ +"use client"; + +export function CurrentYear() { + return <>{new Date().getFullYear()}; +} diff --git a/src/components/layout/Footer.tsx b/src/components/layout/Footer.tsx index f0cd61a0..0e3622a9 100644 --- a/src/components/layout/Footer.tsx +++ b/src/components/layout/Footer.tsx @@ -3,6 +3,7 @@ import Link from "next/link"; import { getTranslations } from "next-intl/server"; import { POLICY_LINKS } from "@/lib/constants/policies"; import { getStoreDescription, getStoreName } from "@/lib/store"; +import { CurrentYear } from "./CurrentYear"; const storeName = getStoreName(); const storeDescription = getStoreDescription(); @@ -149,7 +150,7 @@ export async function Footer({

- © {new Date().getFullYear()} {storeName}. {t("poweredBy")}{" "} + © {storeName}. {t("poweredBy")}{" "}