From 4a90ad910c66982ff01e345f633694b63eec9615 Mon Sep 17 00:00:00 2001 From: Arnaud Date: Wed, 1 Jul 2026 16:20:34 +0200 Subject: [PATCH] feat(deploy): dockerize web app for portainer git-stack deploy behind external traefik MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add root Dockerfile (standalone Next output, multi-stage pnpm build), drop in-stack caddy in favor of publishing web's port for an external traefik LXC (file-provider dynamic config included), and document the portainer deploy flow. Also fixes issues that blocked any production build: a bad auth-client type cast, the ai SDK's mimeType->mediaType rename, an implicit-any callback param, and push.ts eagerly calling webpush.setVapidDetails at module import time (which crashed page-data collection whenever VAPID env vars weren't present at build) — now lazily configured on first send. Co-Authored-By: Claude Sonnet 5 --- .dockerignore | 9 +++++ .env.example | 4 ++ Dockerfile | 43 ++++++++++++++++++++ apps/web/app/(auth)/login/page.tsx | 2 +- apps/web/lib/ai/features/import-photo.ts | 2 +- apps/web/lib/auth/server.ts | 2 +- apps/web/lib/push.ts | 17 +++++--- apps/web/next.config.ts | 1 + docker/Caddyfile | 3 -- docker/DEPLOY.md | 51 ++++++++++++++++++++++++ docker/compose.prod.yml | 45 ++++++++++++--------- docker/traefik/epicure.yml | 24 +++++++++++ 12 files changed, 174 insertions(+), 29 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile delete mode 100644 docker/Caddyfile create mode 100644 docker/DEPLOY.md create mode 100644 docker/traefik/epicure.yml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f3b3b8e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +**/node_modules +**/.next +**/coverage +**/.turbo +.git +.env* +!.env.example +docker +*.md diff --git a/.env.example b/.env.example index 07f70f7..644adf6 100644 --- a/.env.example +++ b/.env.example @@ -53,6 +53,10 @@ SMTP_USER= SMTP_PASS= SMTP_FROM=Epicure +# Web push (generate with: npx web-push generate-vapid-keys) +NEXT_PUBLIC_VAPID_PUBLIC_KEY= +VAPID_PRIVATE_KEY= + # Stripe (optional — webhook stub only) STRIPE_WEBHOOK_SECRET= diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7ffc213 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,43 @@ +# syntax=docker/dockerfile:1 +FROM node:22-alpine AS base +RUN corepack enable + +# ---- deps: install full workspace deps ---- +FROM base AS deps +WORKDIR /repo +COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./ +COPY apps/web/package.json apps/web/package.json +COPY packages/db/package.json packages/db/package.json +COPY packages/api-types/package.json packages/api-types/package.json +RUN pnpm install --frozen-lockfile + +# ---- build ---- +FROM base AS build +WORKDIR /repo +COPY --from=deps /repo/node_modules ./node_modules +COPY --from=deps /repo/apps/web/node_modules ./apps/web/node_modules +COPY --from=deps /repo/packages/db/node_modules ./packages/db/node_modules +COPY --from=deps /repo/packages/api-types/node_modules ./packages/api-types/node_modules +COPY . . +# apps/web/.env.local is normally a symlink to repo-root .env.local (gitignored); +# it doesn't exist in the build context, so Next's env loader chokes on the dangling +# link. No secrets are needed at build time — they're injected at container runtime. +RUN rm -f apps/web/.env.local && touch apps/web/.env.local +ENV NEXT_TELEMETRY_DISABLED=1 +RUN pnpm --filter web build + +# ---- runtime ---- +FROM base AS runner +WORKDIR /app +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 +ENV PORT=3000 +RUN addgroup -g 1001 -S nodejs && adduser -S nextjs -u 1001 + +COPY --from=build /repo/apps/web/public ./public +COPY --from=build --chown=nextjs:nodejs /repo/apps/web/.next/standalone ./ +COPY --from=build --chown=nextjs:nodejs /repo/apps/web/.next/static ./apps/web/.next/static + +USER nextjs +EXPOSE 3000 +CMD ["node", "apps/web/server.js"] diff --git a/apps/web/app/(auth)/login/page.tsx b/apps/web/app/(auth)/login/page.tsx index 12e1715..07da3c0 100644 --- a/apps/web/app/(auth)/login/page.tsx +++ b/apps/web/app/(auth)/login/page.tsx @@ -135,7 +135,7 @@ function SocialButton({ provider, label, icon }: { provider: "github" | "discord function AuthentikButton({ label }: { label: string }) { if (!process.env["NEXT_PUBLIC_AUTHENTIK_ENABLED"]) return null; return ( - diff --git a/apps/web/lib/ai/features/import-photo.ts b/apps/web/lib/ai/features/import-photo.ts index eab651f..e3572e5 100644 --- a/apps/web/lib/ai/features/import-photo.ts +++ b/apps/web/lib/ai/features/import-photo.ts @@ -49,7 +49,7 @@ export async function importFromPhoto( { role: "user", content: [ - { type: "image", image: imageBase64, mimeType }, + { type: "image", image: imageBase64, mediaType: mimeType }, { type: "text", text: "Extract the complete recipe from this image." }, ], }, diff --git a/apps/web/lib/auth/server.ts b/apps/web/lib/auth/server.ts index dde383b..7f7ea8e 100644 --- a/apps/web/lib/auth/server.ts +++ b/apps/web/lib/auth/server.ts @@ -128,7 +128,7 @@ export const auth = betterAuth({ }, changeEmail: { enabled: true, - sendChangeEmailVerification: async ({ newEmail, url }) => { + sendChangeEmailVerification: async ({ newEmail, url }: { newEmail: string; url: string }) => { await sendEmail({ to: newEmail, subject: "Verify your new Epicure email", diff --git a/apps/web/lib/push.ts b/apps/web/lib/push.ts index 8e9319d..cf9c31c 100644 --- a/apps/web/lib/push.ts +++ b/apps/web/lib/push.ts @@ -1,16 +1,23 @@ import webpush from "web-push"; import { db, pushSubscriptions, eq } from "@epicure/db"; -webpush.setVapidDetails( - "mailto:contact@epicure.app", - process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!, - process.env.VAPID_PRIVATE_KEY! -); +let vapidConfigured = false; + +function ensureVapidConfigured(): void { + if (vapidConfigured) return; + webpush.setVapidDetails( + "mailto:contact@epicure.app", + process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!, + process.env.VAPID_PRIVATE_KEY! + ); + vapidConfigured = true; +} export async function sendPushNotification( userId: string, notification: { title: string; body: string; url?: string } ): Promise { + ensureVapidConfigured(); const subs = await db .select() .from(pushSubscriptions) diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 008df9f..f5feba4 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -42,6 +42,7 @@ const securityHeaders = [ ]; const nextConfig: NextConfig = { + output: "standalone", async headers() { return [ { diff --git a/docker/Caddyfile b/docker/Caddyfile deleted file mode 100644 index 1ae18f1..0000000 --- a/docker/Caddyfile +++ /dev/null @@ -1,3 +0,0 @@ -{$DOMAIN} { - reverse_proxy web:3000 -} diff --git a/docker/DEPLOY.md b/docker/DEPLOY.md new file mode 100644 index 0000000..95f639a --- /dev/null +++ b/docker/DEPLOY.md @@ -0,0 +1,51 @@ +# Deploy: Portainer (git stack) + external Traefik LXC + +## Portainer + +1. Stacks → Add stack → **Repository** +2. Repository URL: this repo. Reference: branch to track (e.g. `main`) +3. Compose path: `docker/compose.prod.yml` +4. Environment variables (Portainer stack env, not committed): + +``` +POSTGRES_DB=epicure +POSTGRES_USER=epicure +POSTGRES_PASSWORD= +REDIS_PASSWORD= +MINIO_ROOT_USER= +MINIO_ROOT_PASSWORD= +BETTER_AUTH_SECRET= +BETTER_AUTH_URL=https://HOST_DOMAIN +ENCRYPTION_SECRET= +NEXT_PUBLIC_VAPID_PUBLIC_KEY= +VAPID_PRIVATE_KEY= +WEB_PORT=3000 +# optional +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +OPENROUTER_API_KEY= +``` + +5. Deploy the stack. Portainer builds `web` from the repo's root `Dockerfile` (see `build:` in compose.prod.yml) — no separate image push needed. +6. Enable GitOps updates (webhook or polling) on the stack if you want redeploy-on-push. + +## First deploy: run migrations + seed + +Compose does not auto-migrate. After the stack is up, exec into the `web` container once (or run a one-off container against the same network) with `DATABASE_URL` set, then: + +```bash +pnpm db:migrate +pnpm db:seed # tier definitions — first deploy only +``` + +## Traefik (separate LXC, file provider) + +1. Copy `docker/traefik/epicure.yml` into the traefik LXC's dynamic config directory. +2. Replace `HOST_DOMAIN` with the public hostname and `PORTAINER_LXC_IP` with the portainer LXC's network IP (must match `WEB_PORT` published in compose.prod.yml). +3. Confirm `certResolver` name matches what's set in traefik's static config. +4. Traefik picks it up automatically (file provider watches for changes) — no restart needed. + +## Notes + +- `web` connects to `postgres`/`redis`/`minio` over the compose-internal network; only `web`'s port is published to the LXC host for traefik to reach. +- `apps/web/next.config.ts` has `output: "standalone"` — required for the Dockerfile's slim runtime stage. diff --git a/docker/compose.prod.yml b/docker/compose.prod.yml index fb2a61b..64dee0c 100644 --- a/docker/compose.prod.yml +++ b/docker/compose.prod.yml @@ -30,9 +30,29 @@ services: volumes: - minio_data:/data command: server /data --console-address ":9001" + healthcheck: + test: ["CMD", "mc", "ready", "local"] + interval: 5s + timeout: 5s + retries: 5 + + minio-init: + image: minio/mc:latest + depends_on: + minio: + condition: service_healthy + entrypoint: > + /bin/sh -c " + mc alias set local http://minio:9000 ${MINIO_ROOT_USER} ${MINIO_ROOT_PASSWORD}; + mc mb --ignore-existing local/epicure-uploads; + mc anonymous set download local/epicure-uploads; + exit 0; + " web: - image: epicure-web:latest + build: + context: .. + dockerfile: Dockerfile restart: always environment: DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB} @@ -43,35 +63,24 @@ services: STORAGE_BUCKET: epicure-uploads BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET} BETTER_AUTH_URL: ${BETTER_AUTH_URL} + ENCRYPTION_SECRET: ${ENCRYPTION_SECRET} + NEXT_PUBLIC_VAPID_PUBLIC_KEY: ${NEXT_PUBLIC_VAPID_PUBLIC_KEY} + VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY} GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID} GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET} OPENROUTER_API_KEY: ${OPENROUTER_API_KEY} ports: - - "3000:3000" + # published on portainer LXC host; traefik (other LXC) routes here via :${WEB_PORT} + - "${WEB_PORT:-3000}:3000" depends_on: postgres: condition: service_healthy redis: condition: service_started minio: - condition: service_started - - caddy: - image: caddy:2-alpine - restart: always - ports: - - "80:80" - - "443:443" - volumes: - - ./Caddyfile:/etc/caddy/Caddyfile - - caddy_data:/data - - caddy_config:/config - depends_on: - - web + condition: service_healthy volumes: postgres_data: redis_data: minio_data: - caddy_data: - caddy_config: diff --git a/docker/traefik/epicure.yml b/docker/traefik/epicure.yml new file mode 100644 index 0000000..11fb37d --- /dev/null +++ b/docker/traefik/epicure.yml @@ -0,0 +1,24 @@ +# Traefik file-provider dynamic config. +# Copy to traefik LXC's dynamic config dir (the one referenced by +# providers.file.directory in traefik's static config), then edit +# HOST_DOMAIN and PORTAINER_LXC_IP below. + +http: + routers: + epicure: + rule: "Host(`HOST_DOMAIN`)" + entryPoints: + - websecure + service: epicure + tls: + certResolver: letsencrypt # match whatever resolver name is set in your traefik static config + + services: + epicure: + loadBalancer: + servers: + - url: "http://PORTAINER_LXC_IP:3000" # match WEB_PORT from docker/compose.prod.yml env + healthCheck: + path: / + interval: 10s + timeout: 3s