feat(deploy): dockerize web app for portainer git-stack deploy behind external traefik
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 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
**/node_modules
|
||||
**/.next
|
||||
**/coverage
|
||||
**/.turbo
|
||||
.git
|
||||
.env*
|
||||
!.env.example
|
||||
docker
|
||||
*.md
|
||||
@@ -53,6 +53,10 @@ SMTP_USER=
|
||||
SMTP_PASS=
|
||||
SMTP_FROM=Epicure <noreply@epicure.app>
|
||||
|
||||
# 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=
|
||||
|
||||
|
||||
+43
@@ -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"]
|
||||
@@ -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 (
|
||||
<Button variant="outline" className="w-full gap-2" type="button" onClick={() => (authClient as { signIn: { genericOAuth: (opts: { providerId: string; callbackURL: string }) => Promise<void> } }).signIn.genericOAuth({ providerId: "authentik", callbackURL: "/recipes" })}>
|
||||
<Button variant="outline" className="w-full gap-2" type="button" onClick={() => (authClient as unknown as { signIn: { genericOAuth: (opts: { providerId: string; callbackURL: string }) => Promise<void> } }).signIn.genericOAuth({ providerId: "authentik", callbackURL: "/recipes" })}>
|
||||
<AuthentikIcon />
|
||||
{label}
|
||||
</Button>
|
||||
|
||||
@@ -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." },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
+12
-5
@@ -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<void> {
|
||||
ensureVapidConfigured();
|
||||
const subs = await db
|
||||
.select()
|
||||
.from(pushSubscriptions)
|
||||
|
||||
@@ -42,6 +42,7 @@ const securityHeaders = [
|
||||
];
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
{$DOMAIN} {
|
||||
reverse_proxy web:3000
|
||||
}
|
||||
@@ -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=<generate>
|
||||
REDIS_PASSWORD=<generate>
|
||||
MINIO_ROOT_USER=<generate>
|
||||
MINIO_ROOT_PASSWORD=<generate>
|
||||
BETTER_AUTH_SECRET=<openssl rand -base64 32>
|
||||
BETTER_AUTH_URL=https://HOST_DOMAIN
|
||||
ENCRYPTION_SECRET=<openssl rand -base64 32>
|
||||
NEXT_PUBLIC_VAPID_PUBLIC_KEY=<npx web-push generate-vapid-keys>
|
||||
VAPID_PRIVATE_KEY=<from same command>
|
||||
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.
|
||||
+27
-18
@@ -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 <this-lxc-ip>:${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:
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user