From cd4f49c10d3e406996023019ef39e584f7169304 Mon Sep 17 00:00:00 2001 From: Bayazid Ahmed <86547295+KhBayazidAhmed@users.noreply.github.com> Date: Wed, 19 Aug 2026 09:21:25 +0600 Subject: [PATCH 1/6] feat(docker): add production deployment support --- .dockerignore | 19 +++++++++++++++++ .env.example | 3 +++ Dockerfile | 46 +++++++++++++++++++++++++++++++++++++++++ app/api/health/route.ts | 12 +++++++++++ compose.yaml | 32 ++++++++++++++++++++++++++++ next.config.ts | 1 + 6 files changed, 113 insertions(+) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 app/api/health/route.ts create mode 100644 compose.yaml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..c9c48dc5 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,19 @@ +.git +.github +.next +node_modules +coverage + +.env +.env.* +!.env.example + +Dockerfile* +docker-compose*.yml +docker-compose*.yaml + +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.DS_Store +*.md diff --git a/.env.example b/.env.example index 57f6bae9..1939e639 100644 --- a/.env.example +++ b/.env.example @@ -16,3 +16,6 @@ CRON_SECRET=your-cron-secret # App NEXT_PUBLIC_APP_URL=http://localhost:3000 + +# Host port exposed by Docker Compose (optional) +APP_PORT=3000 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..f849548b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,46 @@ +# syntax=docker/dockerfile:1 + +FROM node:24-alpine AS base +WORKDIR /app +ENV NEXT_TELEMETRY_DISABLED=1 + +FROM base AS dependencies +RUN apk add --no-cache libc6-compat +COPY package.json package-lock.json ./ +RUN --mount=type=cache,target=/root/.npm npm ci --no-audit --no-fund + +FROM base AS builder +ARG NEXT_PUBLIC_SUPABASE_URL +ARG NEXT_PUBLIC_SUPABASE_ANON_KEY +ARG NEXT_PUBLIC_APP_URL +ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL \ + NEXT_PUBLIC_SUPABASE_ANON_KEY=$NEXT_PUBLIC_SUPABASE_ANON_KEY \ + NEXT_PUBLIC_APP_URL=$NEXT_PUBLIC_APP_URL +COPY --from=dependencies /app/node_modules ./node_modules +COPY . . +RUN npm run build + +FROM node:24-alpine AS runner +WORKDIR /app + +ENV NODE_ENV=production \ + NEXT_TELEMETRY_DISABLED=1 \ + HOSTNAME=0.0.0.0 \ + PORT=3000 + +RUN apk add --no-cache libc6-compat \ + && addgroup --system --gid 1001 nodejs \ + && adduser --system --uid 1001 nextjs + +COPY --from=builder --chown=nextjs:nodejs /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +USER nextjs + +EXPOSE 3000 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider "http://127.0.0.1:${PORT}/api/health" || exit 1 + +CMD ["node", "server.js"] diff --git a/app/api/health/route.ts b/app/api/health/route.ts new file mode 100644 index 00000000..4fbf4e8c --- /dev/null +++ b/app/api/health/route.ts @@ -0,0 +1,12 @@ +export const dynamic = "force-dynamic"; + +export function GET() { + return Response.json( + { status: "ok" }, + { + headers: { + "Cache-Control": "no-store", + }, + }, + ); +} diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 00000000..7d6cbb51 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,32 @@ +services: + zernflow: + image: zernflow:latest + build: + context: . + args: + NEXT_PUBLIC_SUPABASE_URL: ${NEXT_PUBLIC_SUPABASE_URL:?Set NEXT_PUBLIC_SUPABASE_URL in .env} + NEXT_PUBLIC_SUPABASE_ANON_KEY: ${NEXT_PUBLIC_SUPABASE_ANON_KEY:?Set NEXT_PUBLIC_SUPABASE_ANON_KEY in .env} + NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL:-http://localhost:3000} + restart: unless-stopped + init: true + environment: + HOSTNAME: 0.0.0.0 + NODE_ENV: production + PORT: 3000 + NEXT_PUBLIC_SUPABASE_URL: ${NEXT_PUBLIC_SUPABASE_URL:?Set NEXT_PUBLIC_SUPABASE_URL in .env} + NEXT_PUBLIC_SUPABASE_ANON_KEY: ${NEXT_PUBLIC_SUPABASE_ANON_KEY:?Set NEXT_PUBLIC_SUPABASE_ANON_KEY in .env} + NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL:-http://localhost:3000} + SUPABASE_SERVICE_ROLE_KEY: ${SUPABASE_SERVICE_ROLE_KEY:?Set SUPABASE_SERVICE_ROLE_KEY in .env} + CRON_SECRET: ${CRON_SECRET:?Set CRON_SECRET in .env} + AI_GATEWAY_API_KEY: ${AI_GATEWAY_API_KEY:-} + ports: + - "${APP_PORT:-3000}:3000" + read_only: true + tmpfs: + - /tmp:rw,noexec,nosuid,size=64m + - /app/.next/cache:rw,noexec,nosuid,size=128m,uid=1001,gid=1001 + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + stop_grace_period: 20s diff --git a/next.config.ts b/next.config.ts index 85eba194..5b406fe6 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,6 +1,7 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { + output: "standalone", images: { remotePatterns: [ { protocol: "https", hostname: "**" }, From 89492d6ff9f1a989b9bd342ce9e1fa574eeff812 Mon Sep 17 00:00:00 2001 From: Bayazid Ahmed <86547295+KhBayazidAhmed@users.noreply.github.com> Date: Wed, 19 Aug 2026 09:21:48 +0600 Subject: [PATCH 2/6] fix(workspace): recover users without workspace setup --- app/setup/page.tsx | 68 +++++++++++++++++++ app/setup/setup-workspace-form.tsx | 57 ++++++++++++++++ lib/actions/workspace.ts | 23 +++++-- lib/workspace.ts | 2 +- .../00017_backfill_user_workspaces.sql | 35 ++++++++++ supabase/migrations/ALL_MIGRATIONS.sql | 38 +++++++++++ 6 files changed, 217 insertions(+), 6 deletions(-) create mode 100644 app/setup/page.tsx create mode 100644 app/setup/setup-workspace-form.tsx create mode 100644 supabase/migrations/00017_backfill_user_workspaces.sql diff --git a/app/setup/page.tsx b/app/setup/page.tsx new file mode 100644 index 00000000..845b8d8e --- /dev/null +++ b/app/setup/page.tsx @@ -0,0 +1,68 @@ +import Image from "next/image"; +import { redirect } from "next/navigation"; +import { Database, Wrench } from "lucide-react"; +import { createClient } from "@/lib/supabase/server"; +import { SetupWorkspaceForm } from "./setup-workspace-form"; + +export default async function SetupPage() { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + + if (!user) redirect("/login"); + + const { data: membership, error } = await supabase + .from("workspace_members") + .select("workspace_id") + .eq("user_id", user.id) + .limit(1) + .maybeSingle(); + + if (membership) redirect("/dashboard"); + + const schemaMissing = error?.code === "PGRST205" || error?.code === "42P01"; + const projectRef = process.env.NEXT_PUBLIC_SUPABASE_URL?.match( + /^https:\/\/([^.]+)\.supabase\.co/, + )?.[1]; + const sqlEditorUrl = projectRef + ? `https://supabase.com/dashboard/project/${projectRef}/sql/new` + : "https://supabase.com/dashboard"; + + return ( +
+
+
+ ZernFlow + +
+ {schemaMissing ? : } +
+ +

+ {schemaMissing ? "Database setup required" : "Finish workspace setup"} +

+

+ {schemaMissing + ? "Your Supabase connection works, but the ZernFlow database tables have not been installed yet." + : "Your account is signed in but does not have a workspace. Create one to continue to the dashboard."} +

+ + {schemaMissing ? ( +
+
    +
  1. 1.Open the Supabase SQL Editor.
  2. +
  3. 2.Copy all of supabase/migrations/ALL_MIGRATIONS.sql from this project.
  4. +
  5. 3.Paste it into a new query and select Run.
  6. +
+

+ Reload this page after the query succeeds. Migration 17 creates a workspace for accounts that already exist. +

+
+ ) : ( + + )} +
+
+ ); +} diff --git a/app/setup/setup-workspace-form.tsx b/app/setup/setup-workspace-form.tsx new file mode 100644 index 00000000..8da9d2ed --- /dev/null +++ b/app/setup/setup-workspace-form.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { ArrowRight, Loader2 } from "lucide-react"; +import { createWorkspace } from "@/lib/actions/workspace"; + +export function SetupWorkspaceForm({ defaultName }: { defaultName: string }) { + const router = useRouter(); + const [name, setName] = useState(defaultName); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + setLoading(true); + setError(null); + + const result = await createWorkspace(name); + if (result.error) { + setError(result.error); + setLoading(false); + return; + } + + router.replace("/dashboard"); + router.refresh(); + } + + return ( +
+
+ + setName(event.target.value)} + required + className="w-full rounded-xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none transition focus:border-sky-400 focus:ring-2 focus:ring-sky-400/20" + /> +
+ + {error &&

{error}

} + + +
+ ); +} diff --git a/lib/actions/workspace.ts b/lib/actions/workspace.ts index a30c0221..7e6c21ae 100644 --- a/lib/actions/workspace.ts +++ b/lib/actions/workspace.ts @@ -1,7 +1,7 @@ "use server"; import { cookies } from "next/headers"; -import { createClient } from "@/lib/supabase/server"; +import { createClient, createServiceClient } from "@/lib/supabase/server"; import { WORKSPACE_COOKIE } from "@/lib/workspace"; export async function switchWorkspace(workspaceId: string) { @@ -45,12 +45,19 @@ export async function createWorkspace(name: string) { const trimmed = name.trim(); if (!trimmed) return { error: "Name is required" }; - const slug = trimmed + const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY; + if (!serviceRoleKey || serviceRoleKey === "your-service-role-key") { + return { error: "Set SUPABASE_SERVICE_ROLE_KEY before creating a workspace" }; + } + + const slugBase = trimmed .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-|-$/g, ""); + const slug = `${slugBase || "workspace"}-${crypto.randomUUID().slice(0, 8)}`; + const serviceClient = await createServiceClient(); - const { data: workspace, error } = await supabase + const { data: workspace, error } = await serviceClient .from("workspaces") .insert({ name: trimmed, slug }) .select("id") @@ -60,13 +67,19 @@ export async function createWorkspace(name: string) { return { error: error?.message || "Failed to create workspace" }; } - // Add user as owner - await supabase.from("workspace_members").insert({ + const { error: membershipError } = await serviceClient + .from("workspace_members") + .insert({ workspace_id: workspace.id, user_id: user.id, role: "owner", }); + if (membershipError) { + await serviceClient.from("workspaces").delete().eq("id", workspace.id); + return { error: membershipError.message }; + } + // Switch to new workspace const cookieStore = await cookies(); cookieStore.set(WORKSPACE_COOKIE, workspace.id, { diff --git a/lib/workspace.ts b/lib/workspace.ts index c7dfd2e5..af4f9c89 100644 --- a/lib/workspace.ts +++ b/lib/workspace.ts @@ -47,7 +47,7 @@ export const getWorkspace = cache(async () => { .limit(1) .single(); - if (!membership?.workspaces) redirect("/login"); + if (!membership?.workspaces) redirect("/setup"); return { user, diff --git a/supabase/migrations/00017_backfill_user_workspaces.sql b/supabase/migrations/00017_backfill_user_workspaces.sql new file mode 100644 index 00000000..1cdc5862 --- /dev/null +++ b/supabase/migrations/00017_backfill_user_workspaces.sql @@ -0,0 +1,35 @@ +-- Accounts created before the workspace trigger was installed need an initial workspace. +do $$ +declare + account record; + workspace_id uuid; + account_name text; +begin + for account in + select users.id, users.email, users.raw_user_meta_data + from auth.users as users + where not exists ( + select 1 + from public.workspace_members as members + where members.user_id = users.id + ) + loop + account_name := coalesce( + account.raw_user_meta_data->>'full_name', + account.raw_user_meta_data->>'name', + split_part(account.email, '@', 1), + 'My' + ); + + insert into public.workspaces (name, slug) + values ( + account_name || '''s Workspace', + lower(regexp_replace(account_name, '[^a-zA-Z0-9]', '-', 'g')) || '-' || substr(account.id::text, 1, 8) + ) + returning id into workspace_id; + + insert into public.workspace_members (workspace_id, user_id, role) + values (workspace_id, account.id, 'owner'); + end loop; +end; +$$; diff --git a/supabase/migrations/ALL_MIGRATIONS.sql b/supabase/migrations/ALL_MIGRATIONS.sql index 72d64625..674f4df4 100644 --- a/supabase/migrations/ALL_MIGRATIONS.sql +++ b/supabase/migrations/ALL_MIGRATIONS.sql @@ -1033,3 +1033,41 @@ ALTER TABLE channels DROP CONSTRAINT IF EXISTS channels_platform_check; ALTER TABLE channels ADD CONSTRAINT channels_platform_check CHECK (platform IN ('facebook', 'instagram', 'twitter', 'telegram', 'bluesky', 'reddit', 'whatsapp')); +-- ============================================================ +-- MIGRATION 17: BACKFILL USER WORKSPACES +-- ============================================================ +-- Accounts created before the workspace trigger was installed need an initial workspace. +do $$ +declare + account record; + workspace_id uuid; + account_name text; +begin + for account in + select users.id, users.email, users.raw_user_meta_data + from auth.users as users + where not exists ( + select 1 + from public.workspace_members as members + where members.user_id = users.id + ) + loop + account_name := coalesce( + account.raw_user_meta_data->>'full_name', + account.raw_user_meta_data->>'name', + split_part(account.email, '@', 1), + 'My' + ); + + insert into public.workspaces (name, slug) + values ( + account_name || '''s Workspace', + lower(regexp_replace(account_name, '[^a-zA-Z0-9]', '-', 'g')) || '-' || substr(account.id::text, 1, 8) + ) + returning id into workspace_id; + + insert into public.workspace_members (workspace_id, user_id, role) + values (workspace_id, account.id, 'owner'); + end loop; +end; +$$; From a55f79b59d928d4ba0c041551a4789778d1ce7f2 Mon Sep 17 00:00:00 2001 From: Bayazid Ahmed <86547295+KhBayazidAhmed@users.noreply.github.com> Date: Wed, 19 Aug 2026 09:21:55 +0600 Subject: [PATCH 3/6] docs: add Docker and migration setup guide --- README.md | 73 +++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 68 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 97317024..870f4716 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ ZernFlow is an open-source alternative to ManyChat. Build visual chatbot flows, ### Prerequisites -- Node.js 18+ +- Node.js 24+ - A [Supabase](https://supabase.com) project (free tier works) - A [Zernio](https://zernio.com) API key (entered in Settings after setup) - A [Vercel AI Gateway](https://vercel.com/ai-gateway) key (optional, for AI node, entered in Settings or env) @@ -52,14 +52,39 @@ npm install 2. **Set up Supabase** -Create a free project at [supabase.com](https://supabase.com). Then run the SQL migrations in the Supabase SQL editor: +Create a free project at [supabase.com](https://supabase.com), then apply the database migrations using either method below. + +#### Supabase SQL Editor + +For a new ZernFlow database: + +1. Open your project in the [Supabase dashboard](https://supabase.com/dashboard). +2. Select **SQL Editor**, then **New query**. +3. Copy the complete contents of `supabase/migrations/ALL_MIGRATIONS.sql` into the editor. +4. Select **Run** and wait for the query to finish successfully. + +For a database that already has ZernFlow tables, run only the new numbered files +from `supabase/migrations/` in ascending order. Do not rerun +`ALL_MIGRATIONS.sql` over an existing schema. + +#### Supabase CLI + +Install or run the latest CLI, authenticate, link the project, and push pending migrations: ```bash -# Run every numbered file in supabase/migrations/ in order, 00001 upwards. -# Skipping later ones leaves features broken: 00016, for example, is what -# lets a WhatsApp channel be stored at all. +npx supabase@latest login +npx supabase@latest link --project-ref your-project-ref +npx supabase@latest db push ``` +Find the project reference in the Supabase URL: for +`https://your-project-ref.supabase.co`, it is `your-project-ref`. The CLI may +prompt for the project's database password. Run `db push` again whenever new +numbered migration files are added. + +Migration `00017_backfill_user_workspaces.sql` provisions a workspace for auth +accounts created before the initial database migration was installed. + 3. **Configure environment** ```bash @@ -86,6 +111,44 @@ npm run dev Open [http://localhost:3000](http://localhost:3000), sign up, and start building flows. +## Docker Deployment + +The production image uses Next.js standalone output and runs as an unprivileged +user. Docker Compose also enables a read-only root filesystem, drops Linux +capabilities, and configures an application health check. + +1. Copy the environment template and provide production values: + +```bash +cp .env.example .env +``` + +Set `NEXT_PUBLIC_APP_URL` to the public HTTPS URL of the deployment. The +`NEXT_PUBLIC_SUPABASE_*` values are embedded into the browser bundle during the +image build; the service-role key and other secrets are provided only when the +container starts. + +2. Build and start the service: + +```bash +docker compose up -d --build +``` + +3. Confirm the deployment is healthy: + +```bash +docker compose ps +curl --fail http://localhost:${APP_PORT:-3000}/api/health +``` + +To publish on another host port, set `APP_PORT` in `.env`. Run scheduled jobs +from your platform's scheduler by calling `/api/cron/jobs` and +`/api/cron/sequences` with `Authorization: Bearer $CRON_SECRET`. + +For a plain Docker deployment without Compose, pass the three +`NEXT_PUBLIC_*` values as build arguments, then provide all values from +`.env.example` as runtime environment variables. + ## Architecture ``` From 0a964c36f1105bd0643ccc2f71a36b6a1630ca87 Mon Sep 17 00:00:00 2001 From: Bayazid Ahmed <86547295+KhBayazidAhmed@users.noreply.github.com> Date: Wed, 19 Aug 2026 09:48:04 +0600 Subject: [PATCH 4/6] Add Hostinger remote Compose deployment --- README.md | 33 +++++++++++++++++++++++++++++++++ compose.hostinger.yaml | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 compose.hostinger.yaml diff --git a/README.md b/README.md index 870f4716..2a91c9a3 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,39 @@ For a plain Docker deployment without Compose, pass the three `NEXT_PUBLIC_*` values as build arguments, then provide all values from `.env.example` as runtime environment variables. +### Hostinger Compose from URL + +Hostinger can download the public repository as a remote Docker build context, +so this deployment does not require GitHub Actions, a container registry, or a +GitHub login on the VPS. + +1. In Hostinger Docker Manager, choose **Compose > Compose from URL** and use: + + ```text + https://raw.githubusercontent.com/KhBayazidAhmed/zernflow/main/compose.hostinger.yaml + ``` + +2. Configure these environment variables in Hostinger before deploying: + + - `NEXT_PUBLIC_SUPABASE_URL` + - `NEXT_PUBLIC_SUPABASE_ANON_KEY` + - `NEXT_PUBLIC_APP_URL` (the public HTTPS application URL) + - `SUPABASE_SERVICE_ROLE_KEY` + - `CRON_SECRET` + - `AI_GATEWAY_API_KEY` (optional) + - `APP_PORT` (optional, defaults to `3000`) + - `SOURCE_REPOSITORY_URL` (optional; use a public fork URL ending in + `.git#main` when deploying modified source) + +`SUPABASE_SERVICE_ROLE_KEY`, `CRON_SECRET`, and `AI_GATEWAY_API_KEY` are runtime +secrets. They are read only when the container runs and are not included in the +image. The `NEXT_PUBLIC_*` values are intentionally public and are supplied as +build arguments by Hostinger because Next.js embeds them in browser code. + +For updates, redeploy or rebuild the application in Hostinger. Docker fetches +the latest source from `main` and builds a fresh local image. A plain container +restart does not rebuild the source. + ## Architecture ``` diff --git a/compose.hostinger.yaml b/compose.hostinger.yaml new file mode 100644 index 00000000..6ffa1187 --- /dev/null +++ b/compose.hostinger.yaml @@ -0,0 +1,33 @@ +services: + zernflow: + image: zernflow:hostinger + pull_policy: build + build: + context: "${SOURCE_REPOSITORY_URL:-https://github.com/KhBayazidAhmed/zernflow.git#main}" + args: + NEXT_PUBLIC_SUPABASE_URL: ${NEXT_PUBLIC_SUPABASE_URL} + NEXT_PUBLIC_SUPABASE_ANON_KEY: ${NEXT_PUBLIC_SUPABASE_ANON_KEY} + NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL} + restart: unless-stopped + init: true + environment: + HOSTNAME: 0.0.0.0 + NODE_ENV: production + PORT: 3000 + NEXT_PUBLIC_SUPABASE_URL: ${NEXT_PUBLIC_SUPABASE_URL} + NEXT_PUBLIC_SUPABASE_ANON_KEY: ${NEXT_PUBLIC_SUPABASE_ANON_KEY} + NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL} + SUPABASE_SERVICE_ROLE_KEY: ${SUPABASE_SERVICE_ROLE_KEY} + CRON_SECRET: ${CRON_SECRET} + AI_GATEWAY_API_KEY: ${AI_GATEWAY_API_KEY:-} + ports: + - "${APP_PORT:-3000}:3000" + read_only: true + tmpfs: + - /tmp:rw,noexec,nosuid,size=64m + - /app/.next/cache:rw,noexec,nosuid,size=128m,uid=1001,gid=1001 + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + stop_grace_period: 20s From b2b25072d9d80c493c99b12933d5322d12cac62d Mon Sep 17 00:00:00 2001 From: Bayazid Ahmed <86547295+KhBayazidAhmed@users.noreply.github.com> Date: Wed, 19 Aug 2026 11:08:49 +0600 Subject: [PATCH 5/6] Avoid Hostinger port 3000 conflict --- README.md | 2 +- compose.hostinger.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 2a91c9a3..5c205be6 100644 --- a/README.md +++ b/README.md @@ -169,7 +169,7 @@ GitHub login on the VPS. - `SUPABASE_SERVICE_ROLE_KEY` - `CRON_SECRET` - `AI_GATEWAY_API_KEY` (optional) - - `APP_PORT` (optional, defaults to `3000`) + - `HOST_PORT` (optional, defaults to `3100`; choose any unused VPS port) - `SOURCE_REPOSITORY_URL` (optional; use a public fork URL ending in `.git#main` when deploying modified source) diff --git a/compose.hostinger.yaml b/compose.hostinger.yaml index 6ffa1187..7ce6c678 100644 --- a/compose.hostinger.yaml +++ b/compose.hostinger.yaml @@ -21,7 +21,7 @@ services: CRON_SECRET: ${CRON_SECRET} AI_GATEWAY_API_KEY: ${AI_GATEWAY_API_KEY:-} ports: - - "${APP_PORT:-3000}:3000" + - "${HOST_PORT:-3100}:3000" read_only: true tmpfs: - /tmp:rw,noexec,nosuid,size=64m From a5485c6ce4bc9a02326f1cb90157a5f256450c6c Mon Sep 17 00:00:00 2001 From: Bayazid Ahmed <86547295+KhBayazidAhmed@users.noreply.github.com> Date: Wed, 19 Aug 2026 12:35:06 +0600 Subject: [PATCH 6/6] Harden Hostinger Compose networking --- .env.example | 3 ++- compose.hostinger.yaml | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index 1939e639..c52e49ee 100644 --- a/.env.example +++ b/.env.example @@ -17,5 +17,6 @@ CRON_SECRET=your-cron-secret # App NEXT_PUBLIC_APP_URL=http://localhost:3000 -# Host port exposed by Docker Compose (optional) +# Host ports exposed by Docker Compose (optional) APP_PORT=3000 +HOST_PORT=3100 diff --git a/compose.hostinger.yaml b/compose.hostinger.yaml index 7ce6c678..9f797d56 100644 --- a/compose.hostinger.yaml +++ b/compose.hostinger.yaml @@ -21,7 +21,7 @@ services: CRON_SECRET: ${CRON_SECRET} AI_GATEWAY_API_KEY: ${AI_GATEWAY_API_KEY:-} ports: - - "${HOST_PORT:-3100}:3000" + - "127.0.0.1:${HOST_PORT:-3100}:3000" read_only: true tmpfs: - /tmp:rw,noexec,nosuid,size=64m