From e912ef2c1b3bdf733fa23f8f9db1e9689c5f9f48 Mon Sep 17 00:00:00 2001 From: Nicolas Pastorello Date: Sat, 11 Jul 2026 22:16:59 -0300 Subject: [PATCH 1/4] feat: add optional Docker / Docker Compose support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Give forkers who self-host on their own infra a first-class container path instead of hand-rolling a Dockerfile and guessing the Next.js standalone wiring. - Multi-stage Dockerfile (deps -> build -> runtime) using Next.js output: "standalone"; runs as a non-root user. - Single-service docker-compose.yml — Supabase stays external, so no database container is included. - .dockerignore and docs/docker.md documenting the setup, including the build-time (NEXT_PUBLIC_*, inlined into the client bundle) vs runtime (server secrets via env_file) split. - Set output: "standalone" in next.config.ts. This is a no-op for next start and the Hostinger path; only the Docker build consumes it. Purely additive: no dependency or stack changes, existing deploy paths are unaffected. Database migrations remain out of scope for the container (applied via the Supabase CLI, matching the external-Supabase model). --- .dockerignore | 17 ++++++++++++++ Dockerfile | 55 ++++++++++++++++++++++++++++++++++++++++++++ README.md | 3 +++ docker-compose.yml | 33 +++++++++++++++++++++++++++ docs/docker.md | 57 ++++++++++++++++++++++++++++++++++++++++++++++ next.config.ts | 5 ++++ 6 files changed, 170 insertions(+) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 docs/docker.md diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5591493 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,17 @@ +.git +.github +.next +node_modules +docs +mcp-server +supabase +*.md +!README.md +.env* +!.env.local.example +Dockerfile +docker-compose*.yml +.dockerignore +.vscode +.idea +coverage diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5edddc3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,55 @@ +# syntax=docker/dockerfile:1 + +# --------------------------------------------------------------- +# Stage 1 — install dependencies (cached until package*.json change) +# --------------------------------------------------------------- +FROM node:22-alpine AS deps +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci + +# --------------------------------------------------------------- +# Stage 2 — build +# +# NEXT_PUBLIC_* values are inlined into the client bundle at build +# time, so they must be provided as build args (docker-compose.yml +# forwards them from .env.local). Server-only secrets (service role +# key, ENCRYPTION_KEY, META_APP_SECRET, ...) are read at runtime and +# must NOT be baked into the image. +# --------------------------------------------------------------- +FROM node:22-alpine AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +ARG NEXT_PUBLIC_SUPABASE_URL +ARG NEXT_PUBLIC_SUPABASE_ANON_KEY +ARG NEXT_PUBLIC_SITE_URL +ARG NEXT_PUBLIC_APP_LOCALE=en +ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL \ + NEXT_PUBLIC_SUPABASE_ANON_KEY=$NEXT_PUBLIC_SUPABASE_ANON_KEY \ + NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL \ + NEXT_PUBLIC_APP_LOCALE=$NEXT_PUBLIC_APP_LOCALE \ + NEXT_TELEMETRY_DISABLED=1 + +RUN npm run build + +# --------------------------------------------------------------- +# Stage 3 — minimal runtime (standalone output) +# --------------------------------------------------------------- +FROM node:22-alpine AS runner +WORKDIR /app +ENV NODE_ENV=production \ + NEXT_TELEMETRY_DISABLED=1 \ + PORT=3000 \ + HOSTNAME=0.0.0.0 + +RUN addgroup -S nextjs && adduser -S nextjs -G nextjs + +COPY --from=builder --chown=nextjs:nextjs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nextjs /app/.next/static ./.next/static +COPY --from=builder --chown=nextjs:nextjs /app/public ./public + +USER nextjs +EXPOSE 3000 +CMD ["node", "server.js"] diff --git a/README.md b/README.md index 9d9bf92..f20c834 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,9 @@ npm run dev Open . You'll be redirected to `/login` (or `/dashboard` if already signed in). +Prefer containers? See [docs/docker.md](./docs/docker.md) for the +Dockerfile + Docker Compose setup. + ## 🚀 Deploy on Hostinger (recommended)

diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1d1fec3 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,33 @@ +name: wacrm + +services: + app: + build: + context: . + # NEXT_PUBLIC_* vars are inlined at build time — forward them + # from .env.local as build args. Changing any of them requires + # a rebuild (docker compose up --build). + args: + NEXT_PUBLIC_SUPABASE_URL: ${NEXT_PUBLIC_SUPABASE_URL} + NEXT_PUBLIC_SUPABASE_ANON_KEY: ${NEXT_PUBLIC_SUPABASE_ANON_KEY} + NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-} + NEXT_PUBLIC_APP_LOCALE: ${NEXT_PUBLIC_APP_LOCALE:-en} + # Runtime (server-only) secrets come from .env.local; they are + # never baked into the image. + env_file: + - .env.local + ports: + - "${PORT:-3000}:3000" + restart: unless-stopped + healthcheck: + test: + [ + "CMD", + "node", + "-e", + "fetch('http://localhost:3000').then((r)=>process.exit(r.ok||r.status<500?0:1)).catch(()=>process.exit(1))", + ] + interval: 30s + timeout: 5s + retries: 3 + start_period: 15s diff --git a/docs/docker.md b/docs/docker.md new file mode 100644 index 0000000..a074869 --- /dev/null +++ b/docs/docker.md @@ -0,0 +1,57 @@ +# Running with Docker + +The repo ships a multi-stage `Dockerfile` (Next.js standalone output, +runs as a non-root user) and a `docker-compose.yml` with a single +`app` service. Supabase is external — point the app at your hosted +(or self-hosted) Supabase project via env vars; no database container +is included. + +## Quick start + +1. Copy the env template and fill it in: + + ```bash + cp .env.local.example .env.local + ``` + +2. Build and start (the `--env-file` flag is required — Compose only + reads `.env` by default for `${VAR}` substitution, and this project + keeps its config in `.env.local`): + + ```bash + docker compose --env-file .env.local up --build -d + ``` + +3. The app is served on [http://localhost:3000](http://localhost:3000) + (override the host port with `PORT=8080` in `.env.local`). + +## Build-time vs runtime variables + +- `NEXT_PUBLIC_*` variables are **inlined into the client bundle at + build time**. They are passed as Docker build args by + `docker-compose.yml`. If you change any of them, rebuild: + `docker compose --env-file .env.local up --build -d`. +- Everything else (`SUPABASE_SERVICE_ROLE_KEY`, `ENCRYPTION_KEY`, + `META_APP_SECRET`, …) is read at **runtime** from `.env.local` via + `env_file` and is never baked into the image — safe to change with + just a container restart. + +## Plain Docker (no Compose) + +```bash +docker build \ + --build-arg NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co \ + --build-arg NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key \ + -t wacrm . + +docker run -d --env-file .env.local -p 3000:3000 wacrm +``` + +## Notes + +- Database migrations under `supabase/` are **not** run by the + container — apply them with the Supabase CLI as described in the + README. +- If you use automation Wait steps, remember to point your cron pinger + at `GET /api/automations/cron` on this deployment (see + `docs/automations-and-cron.md`). diff --git a/next.config.ts b/next.config.ts index 3f38c50..ffd005c 100644 --- a/next.config.ts +++ b/next.config.ts @@ -64,6 +64,11 @@ const SECURITY_HEADERS = [ ] as const; const nextConfig: NextConfig = { + // Emit a self-contained server bundle (.next/standalone) so the + // Docker image can run without node_modules or the Next CLI. + // Harmless outside Docker: `next start` keeps working as before. + output: "standalone", + /** * Cross-origin dev access (Next.js 16). * From 870cfb98f75d5ec55a1bf2ab8bef9002f219afb3 Mon Sep 17 00:00:00 2001 From: Nicolas Pastorello Date: Mon, 27 Jul 2026 16:13:56 -0300 Subject: [PATCH 2/4] fix(docker): use HOST_PORT for the published port MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Setting PORT in .env.local moved the port the server listens on *inside* the container (env_file injects it), while the mapping and the healthcheck stayed on 3000 — so the container came up unreachable. The published port is now HOST_PORT, and PORT is pinned to 3000 via `environment` (which takes precedence over env_file) so a leftover PORT in .env.local can no longer desync the app from the mapping. Documented both, including for the plain-docker path. --- docker-compose.yml | 15 +++++++++++---- docs/docker.md | 10 ++++++++-- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 1d1fec3..3df1e2e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,15 +16,22 @@ services: # never baked into the image. env_file: - .env.local + # The server always listens on 3000 inside the container. Pinned + # here because `environment` wins over `env_file`, so a stray PORT + # in .env.local can't desync the app from the mapping and the + # healthcheck below. + environment: + PORT: 3000 + # Host side only — HOST_PORT=8080 publishes the app on :8080. ports: - - "${PORT:-3000}:3000" + - '${HOST_PORT:-3000}:3000' restart: unless-stopped healthcheck: test: [ - "CMD", - "node", - "-e", + 'CMD', + 'node', + '-e', "fetch('http://localhost:3000').then((r)=>process.exit(r.ok||r.status<500?0:1)).catch(()=>process.exit(1))", ] interval: 30s diff --git a/docs/docker.md b/docs/docker.md index a074869..9834c90 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -23,7 +23,13 @@ is included. ``` 3. The app is served on [http://localhost:3000](http://localhost:3000) - (override the host port with `PORT=8080` in `.env.local`). + (publish it elsewhere with `HOST_PORT=8080` in `.env.local`). + +> Use `HOST_PORT`, not `PORT`, to move the published port. `PORT` is +> what the server listens on _inside_ the container, and `env_file` +> would inject it there — leaving the app on a port the mapping and +> the healthcheck don't target. Compose pins it to 3000 for that +> reason. ## Build-time vs runtime variables @@ -44,7 +50,7 @@ docker build \ --build-arg NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key \ -t wacrm . -docker run -d --env-file .env.local -p 3000:3000 wacrm +docker run -d --env-file .env.local -e PORT=3000 -p 3000:3000 wacrm ``` ## Notes From b4fd27b181f1433ba14e6b749676eaa942589bf2 Mon Sep 17 00:00:00 2001 From: Nicolas Pastorello Date: Mon, 27 Jul 2026 16:20:41 -0300 Subject: [PATCH 3/4] fix(docker): build on node 20 to match CI and engines The image was on node:22-alpine while CI pins node-version: 20 and package.json declares engines.node >=20.0.0. Align all three stages so the container builds and runs on the same major the project is tested against. --- Dockerfile | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index 5edddc3..e32bbea 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,7 @@ # --------------------------------------------------------------- # Stage 1 — install dependencies (cached until package*.json change) # --------------------------------------------------------------- -FROM node:22-alpine AS deps +FROM node:20-alpine AS deps WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci @@ -17,7 +17,7 @@ RUN npm ci # key, ENCRYPTION_KEY, META_APP_SECRET, ...) are read at runtime and # must NOT be baked into the image. # --------------------------------------------------------------- -FROM node:22-alpine AS builder +FROM node:20-alpine AS builder WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . @@ -37,7 +37,7 @@ RUN npm run build # --------------------------------------------------------------- # Stage 3 — minimal runtime (standalone output) # --------------------------------------------------------------- -FROM node:22-alpine AS runner +FROM node:20-alpine AS runner WORKDIR /app ENV NODE_ENV=production \ NEXT_TELEMETRY_DISABLED=1 \ From fbfe07cc70687c3ac4ff0ba38b6d24910a4a1bdd Mon Sep 17 00:00:00 2001 From: Nicolas Pastorello Date: Mon, 27 Jul 2026 16:20:47 -0300 Subject: [PATCH 4/4] docs(docker): drop link to a doc that does not exist docs/automations-and-cron.md was never written. Replace the pointer with the details it was standing in for: both cron endpoints, the x-cron-secret header, and the 503 they return until AUTOMATION_CRON_SECRET is set. --- docs/docker.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/docker.md b/docs/docker.md index 9834c90..59901b9 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -58,6 +58,9 @@ docker run -d --env-file .env.local -e PORT=3000 -p 3000:3000 wacrm - Database migrations under `supabase/` are **not** run by the container — apply them with the Supabase CLI as described in the README. -- If you use automation Wait steps, remember to point your cron pinger - at `GET /api/automations/cron` on this deployment (see - `docs/automations-and-cron.md`). +- Nothing inside the container is scheduled. If you use automation + Wait steps or flows, point an external scheduler at + `GET /api/automations/cron` and `GET /api/flows/cron` on this + deployment, sending the shared secret in the `x-cron-secret` header + (`AUTOMATION_CRON_SECRET`, see `.env.local.example`). Both return + 503 until that variable is set.