diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..338ba8a
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,48 @@
+# Docker build context excludes.
+#
+# WITHOUT this file, the `COPY . .` in the builder stage ships the host's
+# .next/ (2.1 GB, almost all of it the Turbopack dev cache) and node_modules/
+# (639 MB, with darwin-arm64 sharp binaries that are wrong for Alpine) into
+# the build context. That is what filled the production disk.
+
+# Dependencies — reinstalled from the lockfile in the deps stage
+node_modules
+.pnp
+.pnp.*
+.yarn
+
+# Build output — regenerated by `npm run build` in the builder stage.
+# .next/dev alone is 1.8 GB of dev-server cache that must never leave the host.
+.next
+out
+build
+dist
+
+# VCS + local tooling
+.git
+.gitignore
+.github
+.claude
+.vscode
+.idea
+
+# Incremental compiler state (253 KB and host-specific)
+*.tsbuildinfo
+next-env.d.ts
+
+# Secrets — injected at runtime, never baked into a layer
+.env
+.env.*
+*.pem
+
+# Docs and infra that the build does not read
+*.md
+Dockerfile
+.dockerignore
+nginx.conf
+
+# Noise
+.DS_Store
+coverage
+npm-debug.log*
+yarn-error.log*
diff --git a/.gitignore b/.gitignore
index 5ef6a52..c5e8b4f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -19,6 +19,7 @@
# production
/build
+/dist
# misc
.DS_Store
diff --git a/AGENTS.md b/AGENTS.md
index 4c56ba4..f97cfcf 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -83,6 +83,34 @@ Gray is the accent. The **only** colour permitted is semantic: success, warning,
- `StyleOverrides` supports structural pseudo-classes, not just interaction ones —
`':first-child'` emits correctly (used for the table first-column lead-in).
+## Deployment — never ship `.next/` wholesale
+This filled the production disk and took the server down once. A working tree's `.next/`
+reaches **2+ GB**, but almost none of it is runtime state:
+
+| path | size (typical) | ships? |
+|---|---|---|
+| `.next/dev` | 1.8 GB | **no** — Turbopack dev-server cache from `npm run dev` |
+| `.next/cache` | 120–160 MB | **no** — incremental build cache, build host only |
+| `.next/server`, `types`, `trace` | ~24 MB | **no** — inputs to the standalone trace, not read at runtime |
+| `.next/standalone` | 54 MB | **yes** — server.js + traced node_modules |
+| `.next/static` | 3 MB | **yes** — hashed client assets (nginx serves at `/_next/static/`) |
+| `public` | 344 KB | **yes** |
+
+Those last three are the entire payload: **~55 MB unpacked, 18 MB gzipped.**
+- Non-Docker deploy: `npm run bundle` → `dist/loyaly-mer-login.tar.gz`. Never `scp -r .next`.
+- Docker: `.dockerignore` excludes `node_modules` and `.next`. **Do not delete it** — without
+ it `COPY . .` pushes the host's 2 GB `.next` and 639 MB `node_modules` into the build
+ context, including darwin-arm64 sharp binaries that are wrong for Alpine.
+- `npm run clean` drops `.next`, `dist` and the tsbuildinfo when the tree gets heavy.
+
+**`CI_BUILD=1` in container builds.** Next 16.3 enables `turbopackFileSystemCacheForBuild`
+by default; it writes 117 MB to `.next/cache` that only pays off when that directory is
+restored between builds. Docker/Dokploy starts from a clean layer every time, so it is
+written and never read. The Dockerfile sets `CI_BUILD=1`, which flips the flag off in
+`next.config.ts`: **~20% less build CPU** (32s vs 40–43s measured) and `.next` drops
+202 MB → 86 MB. Leave it unset locally — repeat `npm run build` there does reuse the cache.
+It changes no output: the deployable payload is 58 MB either way.
+
## Conventions
- Follow the Astryx rules above: no raw `
` for layout, no hardcoded hex/px, component
props first then token-backed Tailwind utilities.
diff --git a/Dockerfile b/Dockerfile
index 41250eb..8360781 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,10 +1,14 @@
+# syntax=docker/dockerfile:1
+
# Stage 1: Install dependencies
FROM node:22-alpine AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json package-lock.json ./
-RUN npm ci
+# devDeps are required to build (typescript, tailwind, eslint-config-next).
+# This whole stage is discarded — none of it reaches the runner.
+RUN npm ci --no-audit --no-fund
# Stage 2: Build the Next.js application
FROM node:22-alpine AS builder
@@ -14,11 +18,16 @@ COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_ENV=production
+# Each Docker build starts from a clean layer, so Turbopack's .next/cache is
+# written but never restored. Skipping it cuts ~20% of build CPU (the metric
+# that matters on a 1-vCPU host) and 116 MB off this layer.
+ENV CI_BUILD=1
RUN npm run build
# Stage 3: Production runner with Next.js Standalone
FROM node:22-alpine AS runner
+RUN apk add --no-cache libc6-compat
WORKDIR /app
ENV NODE_ENV=production
@@ -26,10 +35,18 @@ ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
-# Copy public static assets and standalone build output
-COPY --from=builder /app/public ./public
-COPY --from=builder /app/.next/standalone ./
-COPY --from=builder /app/.next/static ./.next/static
+# Run as a non-root user; nextjs owns nothing it does not need to write.
+RUN addgroup -g 1001 -S nodejs && adduser -u 1001 -S nextjs -G nodejs
+
+# Copy public static assets and standalone build output.
+# These three paths are the ENTIRE runtime payload (~57 MB). Never copy the
+# whole .next/ directory here — .next/dev and .next/cache are build-host-only
+# and account for ~1.96 GB.
+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 3000
diff --git a/eslint.config.mjs b/eslint.config.mjs
index 4fe5a79..0aa8e8b 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -11,6 +11,8 @@ const eslintConfig = defineConfig([
".next/**",
"out/**",
"build/**",
+ // Deploy artifact from `npm run bundle` — traced vendor code, not ours.
+ "dist/**",
"next-env.d.ts",
// Generated by `npm run theme:build` — edit src/theme/loyalyTheme.ts
// instead. The emitted .d.ts uses a triple-slash reference we do not own.
diff --git a/next.config.ts b/next.config.ts
index 7356919..be4da9c 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -2,6 +2,14 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
+ experimental: {
+ // Turbopack's build cache lives in .next/cache and only pays off when that
+ // directory survives between builds. A Docker/Dokploy build starts from a
+ // clean layer every time, so the cache is written and never read — ~117 MB
+ // of pure write cost per build. CI_BUILD is set in the Dockerfile only, so
+ // local `npm run build` keeps its warm cache.
+ turbopackFileSystemCacheForBuild: process.env.CI_BUILD !== "1",
+ },
allowedDevOrigins: ["192.168.0.117", "192.168.0.*", "192.168.1.*", "localhost", "127.0.0.1"],
images: {
remotePatterns: [
diff --git a/package.json b/package.json
index 5e8065a..f1e5e81 100644
--- a/package.json
+++ b/package.json
@@ -7,6 +7,8 @@
"build": "next build",
"start": "next start -p 3100",
"lint": "eslint",
+ "clean": "rm -rf .next dist tsconfig.tsbuildinfo",
+ "bundle": "bash scripts/bundle.sh",
"theme:build": "astryx theme build src/theme/loyalyTheme.ts",
"typecheck": "tsc --noEmit",
"dev:preview": "next dev"
diff --git a/public/file.svg b/public/file.svg
deleted file mode 100644
index 004145c..0000000
--- a/public/file.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/public/globe.svg b/public/globe.svg
deleted file mode 100644
index 567f17b..0000000
--- a/public/globe.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/public/lyts.png b/public/lyts.png
deleted file mode 100644
index 983627e..0000000
Binary files a/public/lyts.png and /dev/null differ
diff --git a/public/next.svg b/public/next.svg
deleted file mode 100644
index 5174b28..0000000
--- a/public/next.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/public/vercel.svg b/public/vercel.svg
deleted file mode 100644
index 7705396..0000000
--- a/public/vercel.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/public/window.svg b/public/window.svg
deleted file mode 100644
index b2b2a44..0000000
--- a/public/window.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/scripts/bundle.sh b/scripts/bundle.sh
new file mode 100755
index 0000000..f103472
--- /dev/null
+++ b/scripts/bundle.sh
@@ -0,0 +1,49 @@
+#!/usr/bin/env bash
+#
+# Produce the deployable artifact for a non-Docker deploy.
+#
+# Copying .next/ wholesale is what filled the production disk: a working tree's
+# .next/ reaches 2+ GB, but only three paths are read at runtime —
+# .next/standalone the server + its traced node_modules
+# .next/static hashed client assets (served by nginx at /_next/static/)
+# public unhashed public assets
+# Everything else (.next/cache, .next/dev, .next/server, .next/types) is
+# build-host state and must never be shipped.
+#
+# Usage: npm run bundle -> dist/loyaly-mer-login.tar.gz
+
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+cd "$ROOT"
+
+OUT="dist"
+STAGE="$OUT/bundle"
+
+for p in .next/standalone .next/static public; do
+ if [ ! -d "$p" ]; then
+ echo "error: $p missing — run 'npm run build' first" >&2
+ exit 1
+ fi
+done
+
+rm -rf "$STAGE"
+mkdir -p "$STAGE"
+
+# standalone already contains server.js, package.json and traced node_modules,
+# and expects static/ and public/ to sit beside it in the same layout.
+cp -R .next/standalone/. "$STAGE/"
+mkdir -p "$STAGE/.next"
+cp -R .next/static "$STAGE/.next/static"
+cp -R public "$STAGE/public"
+
+TARBALL="$OUT/loyaly-mer-login.tar.gz"
+rm -f "$TARBALL"
+tar -czf "$TARBALL" -C "$STAGE" .
+
+echo
+echo "bundle: $(du -sh "$STAGE" | cut -f1) ($STAGE)"
+echo "tarball: $(du -sh "$TARBALL" | cut -f1) ($TARBALL)"
+echo
+echo "deploy: scp $TARBALL :/srv/ && tar -xzf loyaly-mer-login.tar.gz -C /srv/app"
+echo "run: PORT=3000 HOSTNAME=0.0.0.0 NODE_ENV=production node server.js"