feat(deploy): add Docker support with multi-stage build and compose
This commit is contained in:
parent
65b20f9ef9
commit
374bba1a93
8 changed files with 271 additions and 24 deletions
28
.playwright-mcp/page-2026-05-15T17-28-43-865Z.yml
Normal file
28
.playwright-mcp/page-2026-05-15T17-28-43-865Z.yml
Normal file
|
|
@ -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]
|
||||||
|
|
@ -1,41 +1,49 @@
|
||||||
{
|
{
|
||||||
"active_plan": "/home/nxtkofi/dev/templates/convex-next-saas/.sisyphus/plans/app-shell-and-route-fallbacks.md",
|
"active_plan": "/home/nxtkofi/dev/templates/convex-next-saas/.sisyphus/plans/pwa-lite.md",
|
||||||
"started_at": "2026-04-21T20:19:23.457Z",
|
"started_at": "2026-05-15T17:16:56.579Z",
|
||||||
"session_ids": [
|
"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",
|
"agent": "atlas",
|
||||||
"task_sessions": {
|
"task_sessions": {
|
||||||
"todo:1": {
|
"todo:1": {
|
||||||
"task_key": "todo:1",
|
"task_key": "todo:1",
|
||||||
"task_label": "1",
|
"task_label": "1",
|
||||||
"task_title": "Establish shell and fallback translation contract",
|
"task_title": "Add lightweight PWA manifest, icon assets, and metadata",
|
||||||
"session_id": "ses_24e43150cffe5Wc6MKMRzAzPi5",
|
"session_id": "ses_1d35a7e6effe7tixxqSuYdTznh",
|
||||||
"agent": "Sisyphus-Junior",
|
"agent": "Sisyphus-Junior",
|
||||||
"category": "quick",
|
"category": "quick",
|
||||||
"updated_at": "2026-04-21T20:40:03.597Z"
|
"updated_at": "2026-05-15T17:20:57.345Z"
|
||||||
},
|
},
|
||||||
"todo:2": {
|
"todo:2": {
|
||||||
"task_key": "todo:2",
|
"task_key": "todo:2",
|
||||||
"task_label": "2",
|
"task_label": "2",
|
||||||
"task_title": "Build shared shell primitives and refactor ThemeChanger for header use",
|
"task_title": "Document PWA customization checklist and store caveats",
|
||||||
"session_id": "ses_24e395669ffe5qSSbdGO6z1tAh",
|
"session_id": "ses_1d3563c89ffeh696YfGmAPPgMU",
|
||||||
"agent": "Sisyphus-Junior",
|
"agent": "Sisyphus-Junior",
|
||||||
"category": "visual-engineering",
|
"category": "quick",
|
||||||
"updated_at": "2026-04-21T20:41:37.491Z"
|
"updated_at": "2026-05-15T17:24:56.130Z"
|
||||||
},
|
},
|
||||||
"final-wave:f1": {
|
"final-wave:f1": {
|
||||||
"task_key": "final-wave:f1",
|
"task_key": "final-wave:f1",
|
||||||
"task_label": "F1",
|
"task_label": "F1",
|
||||||
"task_title": "Plan Compliance Audit — oracle",
|
"task_title": "Plan Compliance Audit — oracle",
|
||||||
"session_id": "ses_24e202cbbffe7XpuKb4O1csa5w",
|
"session_id": "ses_1d3538648ffejpOJeqKzTUP8fB",
|
||||||
"agent": "Sisyphus-Junior",
|
"agent": "oracle",
|
||||||
"category": "unspecified-high",
|
"category": "",
|
||||||
"updated_at": "2026-04-21T21:12:31.731Z"
|
"updated_at": "2026-05-15T17:40:02.032Z"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"session_origins": {
|
|
||||||
"ses_24eb26a4bffeSxb1coIit8a4Dv": "direct"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -268,3 +268,89 @@ Do not add aggressive caching for authenticated SaaS pages (dashboard, settings)
|
||||||
### Store distribution
|
### Store distribution
|
||||||
- **Google Play**: requires a native/TWA wrapper such as Bubblewrap. The manifest alone is not enough.
|
- **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.
|
- **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
|
||||||
|
```
|
||||||
|
|
|
||||||
40
Dockerfile
Normal file
40
Dockerfile
Normal file
|
|
@ -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"]
|
||||||
|
|
@ -29,6 +29,7 @@ A personal, opinionated SaaS template built for speed. Next.js 16 + Convex self-
|
||||||
- [x] GitHub Actions CI (lint + build)
|
- [x] GitHub Actions CI (lint + build)
|
||||||
- [x] Project init script (`bin/init-template.mjs`)
|
- [x] Project init script (`bin/init-template.mjs`)
|
||||||
- [x] PWA Lite installability (manifest + icons)
|
- [x] PWA Lite installability (manifest + icons)
|
||||||
|
- [x] Docker deployment (Dockerfile + docker-compose)
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,15 @@ const EXCLUDE = [
|
||||||
'node_modules',
|
'node_modules',
|
||||||
'.next',
|
'.next',
|
||||||
'.sisyphus',
|
'.sisyphus',
|
||||||
|
'.sisyphus',
|
||||||
|
'.memsearch',
|
||||||
|
'.playwright-mcp',
|
||||||
'pnpm-lock.yaml',
|
'pnpm-lock.yaml',
|
||||||
'bin',
|
'bin',
|
||||||
|
'features.md',
|
||||||
|
'.env.local',
|
||||||
|
'DEVELOPMENT.md',
|
||||||
|
'tmuxi.template.yml',
|
||||||
];
|
];
|
||||||
|
|
||||||
function copyRecursive(src, dest, replacements) {
|
function copyRecursive(src, dest, replacements) {
|
||||||
|
|
@ -23,6 +30,7 @@ function copyRecursive(src, dest, replacements) {
|
||||||
copyRecursive(path.join(src, entry), path.join(dest, entry), replacements);
|
copyRecursive(path.join(src, entry), path.join(dest, entry), replacements);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
if (EXCLUDE.includes(path.basename(src))) return;
|
||||||
let content = fs.readFileSync(src, 'utf-8');
|
let content = fs.readFileSync(src, 'utf-8');
|
||||||
for (const [key, value] of Object.entries(replacements)) {
|
for (const [key, value] of Object.entries(replacements)) {
|
||||||
content = content.split(key).join(value);
|
content = content.split(key).join(value);
|
||||||
|
|
@ -35,7 +43,7 @@ function main() {
|
||||||
const projectName = process.argv[2];
|
const projectName = process.argv[2];
|
||||||
|
|
||||||
if (!projectName) {
|
if (!projectName) {
|
||||||
console.error('Usage: node bin/init-template.js <project-name>');
|
console.error('Usage: node bin/init-template.mjs <project-name>');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -63,15 +71,33 @@ function main() {
|
||||||
'~/vaults/mentat/': '~/workspace/',
|
'~/vaults/mentat/': '~/workspace/',
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log(`Creating ${projectName}...`);
|
// Safety: exclude target dir name to prevent recursion if run inside template
|
||||||
copyRecursive(templateDir, targetDir, replacements);
|
EXCLUDE.push(projectName);
|
||||||
|
|
||||||
|
const srcDir = path.join(targetDir, 'src');
|
||||||
|
const docsDir = path.join(targetDir, 'docs');
|
||||||
|
|
||||||
|
console.log(`Creating ${projectName}...`);
|
||||||
|
|
||||||
|
// Copy template files into <name>/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' });
|
execSync('git init', { cwd: targetDir, stdio: 'inherit' });
|
||||||
|
|
||||||
console.log('Installing dependencies...');
|
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();
|
main();
|
||||||
|
|
|
||||||
58
docker-compose.yml
Normal file
58
docker-compose.yml
Normal file
|
|
@ -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
|
||||||
|
|
@ -2,7 +2,7 @@ import type { NextConfig } from "next";
|
||||||
import createNextIntlPlugin from "next-intl/plugin";
|
import createNextIntlPlugin from "next-intl/plugin";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
output: 'standalone',
|
||||||
};
|
};
|
||||||
const withNextIntl = createNextIntlPlugin();
|
const withNextIntl = createNextIntlPlugin();
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue