From 374bba1a9366c56379b6cc152aff46d586158d28 Mon Sep 17 00:00:00 2001 From: nxtkofi Date: Sun, 17 May 2026 18:48:34 +0200 Subject: [PATCH] feat(deploy): add Docker support with multi-stage build and compose --- .../page-2026-05-15T17-28-43-865Z.yml | 28 ++++++ .sisyphus/boulder.json | 44 ++++++---- DEVELOPMENT.md | 86 +++++++++++++++++++ Dockerfile | 40 +++++++++ README.md | 1 + bin/init-template.mjs | 36 ++++++-- docker-compose.yml | 58 +++++++++++++ next.config.ts | 2 +- 8 files changed, 271 insertions(+), 24 deletions(-) create mode 100644 .playwright-mcp/page-2026-05-15T17-28-43-865Z.yml create mode 100644 Dockerfile create mode 100644 docker-compose.yml diff --git a/.playwright-mcp/page-2026-05-15T17-28-43-865Z.yml b/.playwright-mcp/page-2026-05-15T17-28-43-865Z.yml new file mode 100644 index 0000000..0ae62a9 --- /dev/null +++ b/.playwright-mcp/page-2026-05-15T17-28-43-865Z.yml @@ -0,0 +1,28 @@ +- generic [active] [ref=e1]: + - main [ref=e2]: + - generic [ref=e3]: + - generic [ref=e6]: + - link "Strona główna" [ref=e8] [cursor=pointer]: + - /url: / + - generic [ref=e12]: + - button "Light mode" [ref=e13]: + - img + - button "Dark mode" [ref=e14]: + - img + - main [ref=e15]: + - heading "Witaj świecie!" [level=1] [ref=e16] + - generic [ref=e17]: + - button "Light mode" [ref=e18]: + - img + - button "Dark mode" [ref=e19]: + - img + - region "Notifications alt+T" + - generic [ref=e21]: + - generic [ref=e22]: + - generic [ref=e23]: Używamy plików cookie + - generic [ref=e24]: Używamy plików cookie, aby poprawić Twoje doświadczenie. Niezbędne pliki cookie są zawsze aktywne. Możesz zarządzać preferencjami poniżej. + - generic [ref=e26]: + - button "Akceptuj wszystkie" [ref=e27] + - button "Akceptuj tylko niezbędne" [ref=e28] + - button "Zarządzaj preferencjami" [ref=e29] + - alert [ref=e30] \ No newline at end of file diff --git a/.sisyphus/boulder.json b/.sisyphus/boulder.json index 19cbf35..47e4646 100644 --- a/.sisyphus/boulder.json +++ b/.sisyphus/boulder.json @@ -1,41 +1,49 @@ { - "active_plan": "/home/nxtkofi/dev/templates/convex-next-saas/.sisyphus/plans/app-shell-and-route-fallbacks.md", - "started_at": "2026-04-21T20:19:23.457Z", + "active_plan": "/home/nxtkofi/dev/templates/convex-next-saas/.sisyphus/plans/pwa-lite.md", + "started_at": "2026-05-15T17:16:56.579Z", "session_ids": [ - "ses_24eb26a4bffeSxb1coIit8a4Dv" + "ses_1d399daaaffebnoS7swOtGeMrQ", + "ses_1d3539915ffep5QJEbHVd0wtbn", + "ses_1d35391a6ffeamL03zDOdqK95a", + "ses_1d3538b00ffeXlbkMEaiJiAGib", + "ses_1d3538648ffejpOJeqKzTUP8fB" ], - "plan_name": "app-shell-and-route-fallbacks", + "session_origins": { + "ses_1d399daaaffebnoS7swOtGeMrQ": "direct", + "ses_1d3539915ffep5QJEbHVd0wtbn": "appended", + "ses_1d35391a6ffeamL03zDOdqK95a": "appended", + "ses_1d3538b00ffeXlbkMEaiJiAGib": "appended", + "ses_1d3538648ffejpOJeqKzTUP8fB": "appended" + }, + "plan_name": "pwa-lite", "agent": "atlas", "task_sessions": { "todo:1": { "task_key": "todo:1", "task_label": "1", - "task_title": "Establish shell and fallback translation contract", - "session_id": "ses_24e43150cffe5Wc6MKMRzAzPi5", + "task_title": "Add lightweight PWA manifest, icon assets, and metadata", + "session_id": "ses_1d35a7e6effe7tixxqSuYdTznh", "agent": "Sisyphus-Junior", "category": "quick", - "updated_at": "2026-04-21T20:40:03.597Z" + "updated_at": "2026-05-15T17:20:57.345Z" }, "todo:2": { "task_key": "todo:2", "task_label": "2", - "task_title": "Build shared shell primitives and refactor ThemeChanger for header use", - "session_id": "ses_24e395669ffe5qSSbdGO6z1tAh", + "task_title": "Document PWA customization checklist and store caveats", + "session_id": "ses_1d3563c89ffeh696YfGmAPPgMU", "agent": "Sisyphus-Junior", - "category": "visual-engineering", - "updated_at": "2026-04-21T20:41:37.491Z" + "category": "quick", + "updated_at": "2026-05-15T17:24:56.130Z" }, "final-wave:f1": { "task_key": "final-wave:f1", "task_label": "F1", "task_title": "Plan Compliance Audit — oracle", - "session_id": "ses_24e202cbbffe7XpuKb4O1csa5w", - "agent": "Sisyphus-Junior", - "category": "unspecified-high", - "updated_at": "2026-04-21T21:12:31.731Z" + "session_id": "ses_1d3538648ffejpOJeqKzTUP8fB", + "agent": "oracle", + "category": "", + "updated_at": "2026-05-15T17:40:02.032Z" } - }, - "session_origins": { - "ses_24eb26a4bffeSxb1coIit8a4Dv": "direct" } } \ No newline at end of file diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index f0b58f2..7542996 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -268,3 +268,89 @@ Do not add aggressive caching for authenticated SaaS pages (dashboard, settings) ### Store distribution - **Google Play**: requires a native/TWA wrapper such as Bubblewrap. The manifest alone is not enough. - **Apple App Store**: usually rejects simple repackaged websites without native value. A PWA wrapper alone is not sufficient. + +## Docker Deployment + +The template includes a `Dockerfile` and `docker-compose.yml` for containerized deployment. + +### Dockerfile + +Multi-stage build optimized for production: + +- **deps**: Installs dependencies +- **builder**: Builds the Next.js app +- **runner**: Minimal image with only production artifacts + +```bash +# Build locally +docker build -t my-app . + +# Run locally +docker run -p 3000:3000 --env-file .env.local my-app +``` + +### Docker Compose + +Full-stack deployment with Convex backend: + +```bash +# Start everything (Next.js + Convex backend + Convex dashboard) +docker compose up +``` + +Services: + +| Service | Port | Description | +|---------|------|-------------| +| `app` | 3000 | Next.js application | +| `backend` | 3210 | Convex backend API | +| `dashboard` | 6791 | Convex admin dashboard | + +### Server Components in Docker + +Next.js 16 App Router with Server Components (RSC) works identically in Docker: + +- **Build time**: `next build` generates static pages + server bundles +- **Runtime**: `next start` serves both static and server-rendered content +- **No special config needed** — `output: 'standalone'` in `next.config.ts` handles everything + +The Dockerfile uses `output: 'standalone'` which creates a self-contained server bundle. This means: +- Smaller image size (only production files) +- Faster startup +- No need for `node_modules` in final image + +### Environment Variables + +When running in Docker, pass env vars via: + +1. `.env` file in the same directory as `docker-compose.yml` +2. `environment` section in `docker-compose.yml` +3. `-e` flags with `docker run` + +Required variables: + +```bash +NEXT_PUBLIC_SITE_URL=https://your-domain.com +NEXT_PUBLIC_CONVEX_URL=https://your-convex-backend.example.com +NEXT_PUBLIC_CONVEX_SITE_URL=https://your-convex-site.example.com +CONVEX_SELF_HOSTED_URL=https://your-convex-backend.example.com +CONVEX_SELF_HOSTED_ADMIN_KEY=your-admin-key +``` + +### Coolify Deployment + +1. Push code to your Forgejo repo +2. In Coolify, create a new service from your repo +3. Select `docker-compose.yml` as the deployment method +4. Set environment variables in Coolify UI +5. Deploy + +> **Note**: The `app` service depends on `backend` (Convex). Make sure your Convex backend is accessible from the Coolify server. + +### Health Check + +The Next.js app exposes a health endpoint at `/api/health` (add if needed) or you can check if the app is running: + +```bash +curl http://localhost:3000 +``` diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..31edff2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,40 @@ +# syntax=docker/dockerfile:1 + +# Stage 1: Dependencies +FROM node:20-slim AS deps +WORKDIR /app + +# Enable pnpm +RUN corepack enable && corepack prepare pnpm@9 --activate + +COPY package.json pnpm-lock.yaml ./ +RUN pnpm install --frozen-lockfile + +# Stage 2: Builder +FROM node:20-slim AS builder +WORKDIR /app + +RUN corepack enable && corepack prepare pnpm@9 --activate + +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +# Build the application +RUN pnpm build + +# Stage 3: Runner +FROM node:20-slim AS runner +WORKDIR /app + +ENV NODE_ENV=production +ENV PORT=3000 +ENV HOSTNAME=0.0.0.0 + +# Copy necessary files +COPY --from=builder /app/public ./public +COPY --from=builder /app/.next/standalone ./ +COPY --from=builder /app/.next/static ./.next/static + +EXPOSE 3000 + +CMD ["node", "server.js"] diff --git a/README.md b/README.md index e33af96..93a9611 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ A personal, opinionated SaaS template built for speed. Next.js 16 + Convex self- - [x] GitHub Actions CI (lint + build) - [x] Project init script (`bin/init-template.mjs`) - [x] PWA Lite installability (manifest + icons) +- [x] Docker deployment (Dockerfile + docker-compose) ## Quick Start diff --git a/bin/init-template.mjs b/bin/init-template.mjs index 5f87a1d..4ab58cd 100644 --- a/bin/init-template.mjs +++ b/bin/init-template.mjs @@ -10,8 +10,15 @@ const EXCLUDE = [ 'node_modules', '.next', '.sisyphus', + '.sisyphus', + '.memsearch', + '.playwright-mcp', 'pnpm-lock.yaml', 'bin', + 'features.md', + '.env.local', + 'DEVELOPMENT.md', + 'tmuxi.template.yml', ]; function copyRecursive(src, dest, replacements) { @@ -23,6 +30,7 @@ function copyRecursive(src, dest, replacements) { copyRecursive(path.join(src, entry), path.join(dest, entry), replacements); } } else { + if (EXCLUDE.includes(path.basename(src))) return; let content = fs.readFileSync(src, 'utf-8'); for (const [key, value] of Object.entries(replacements)) { content = content.split(key).join(value); @@ -35,7 +43,7 @@ function main() { const projectName = process.argv[2]; if (!projectName) { - console.error('Usage: node bin/init-template.js '); + console.error('Usage: node bin/init-template.mjs '); process.exit(1); } @@ -63,15 +71,33 @@ function main() { '~/vaults/mentat/': '~/workspace/', }; - console.log(`Creating ${projectName}...`); - copyRecursive(templateDir, targetDir, replacements); + // Safety: exclude target dir name to prevent recursion if run inside template + EXCLUDE.push(projectName); + const srcDir = path.join(targetDir, 'src'); + const docsDir = path.join(targetDir, 'docs'); + + console.log(`Creating ${projectName}...`); + + // Copy template files into /src/ + copyRecursive(templateDir, srcDir, replacements); + + // Move README.md to docs/readme.md + const readmeSrc = path.join(srcDir, 'README.md'); + const readmeDest = path.join(docsDir, 'readme.md'); + if (fs.existsSync(readmeSrc)) { + fs.mkdirSync(docsDir, { recursive: true }); + fs.renameSync(readmeSrc, readmeDest); + } + + console.log('Initializing git...'); execSync('git init', { cwd: targetDir, stdio: 'inherit' }); console.log('Installing dependencies...'); - execSync('pnpm install', { cwd: targetDir, stdio: 'inherit' }); + execSync('pnpm install', { cwd: srcDir, stdio: 'inherit' }); - console.log(`\nDone! cd ${projectName} && pnpm dev --webpack`); + console.log(`\nDone!`); + console.log(` cd ${projectName}/src && pnpm dev --webpack`); } main(); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..28b1da0 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,58 @@ +services: + # Next.js Application + app: + build: + context: . + dockerfile: Dockerfile + ports: + - '${PORT:-3000}:3000' + environment: + NODE_ENV: production + NEXT_PUBLIC_SITE_URL: '${NEXT_PUBLIC_SITE_URL}' + NEXT_PUBLIC_CONVEX_URL: '${NEXT_PUBLIC_CONVEX_URL}' + NEXT_PUBLIC_CONVEX_SITE_URL: '${NEXT_PUBLIC_CONVEX_SITE_URL}' + CONVEX_SELF_HOSTED_URL: '${CONVEX_SELF_HOSTED_URL}' + CONVEX_SELF_HOSTED_ADMIN_KEY: '${CONVEX_SELF_HOSTED_ADMIN_KEY}' + depends_on: + - backend + + # Convex Backend + backend: + image: 'ghcr.io/get-convex/convex-backend:latest' + stop_grace_period: 10s + stop_signal: SIGINT + ports: + - '${PORT:-3210}:3210' + - '${SITE_PROXY_PORT:-3211}:3211' + volumes: + - 'data:/convex/data' + environment: + INSTANCE_NAME: '${INSTANCE_NAME}' + INSTANCE_SECRET: '${INSTANCE_SECRET}' + CONVEX_CLOUD_ORIGIN: '${SERVICE_URL_BACKEND}' + CONVEX_SITE_ORIGIN: '${SERVICE_URL_BACKEND_SITE}' + DO_NOT_REQUIRE_SSL: '${DO_NOT_REQUIRE_SSL:-true}' + DATABASE_URL: '${DATABASE_URL:-}' + healthcheck: + test: ['CMD-SHELL', 'curl -f http://localhost:3210/version'] + interval: 5s + start_period: 10s + timeout: 5s + retries: 10 + + # Convex Dashboard + dashboard: + image: 'ghcr.io/get-convex/convex-dashboard:latest' + stop_grace_period: 10s + stop_signal: SIGINT + ports: + - '${DASHBOARD_PORT:-6791}:6791' + environment: + PORT: '6791' + NEXT_PUBLIC_DEPLOYMENT_URL: '${SERVICE_URL_BACKEND}' + depends_on: + backend: + condition: service_healthy + +volumes: + data: null diff --git a/next.config.ts b/next.config.ts index 8552a02..2db2750 100644 --- a/next.config.ts +++ b/next.config.ts @@ -2,7 +2,7 @@ import type { NextConfig } from "next"; import createNextIntlPlugin from "next-intl/plugin"; const nextConfig: NextConfig = { - /* config options here */ + output: 'standalone', }; const withNextIntl = createNextIntlPlugin();