Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,7 @@ CRON_SECRET=your-cron-secret

# App
NEXT_PUBLIC_APP_URL=http://localhost:3000

# Host ports exposed by Docker Compose (optional)
APP_PORT=3000
HOST_PORT=3100
46 changes: 46 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
106 changes: 101 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -86,6 +111,77 @@ 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.

### 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)
- `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)

`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

```
Expand Down
12 changes: 12 additions & 0 deletions app/api/health/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export const dynamic = "force-dynamic";

export function GET() {
return Response.json(
{ status: "ok" },
{
headers: {
"Cache-Control": "no-store",
},
},
);
}
68 changes: 68 additions & 0 deletions app/setup/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<main className="relative flex min-h-screen items-center justify-center overflow-hidden bg-slate-950 px-6 py-16 text-white">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(14,165,233,0.22),transparent_38%),radial-gradient(circle_at_bottom_right,rgba(16,185,129,0.18),transparent_34%)]" />
<div className="relative w-full max-w-lg rounded-3xl border border-white/10 bg-white/[0.07] p-8 shadow-2xl backdrop-blur-xl sm:p-10">
<Image src="/logo.png" alt="ZernFlow" width={48} height={48} className="mb-8 rounded-xl" />

<div className="mb-5 flex h-12 w-12 items-center justify-center rounded-2xl bg-sky-400/15 text-sky-300">
{schemaMissing ? <Database className="h-6 w-6" /> : <Wrench className="h-6 w-6" />}
</div>

<h1 className="text-3xl font-semibold tracking-tight">
{schemaMissing ? "Database setup required" : "Finish workspace setup"}
</h1>
<p className="mt-3 leading-7 text-slate-300">
{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."}
</p>

{schemaMissing ? (
<div className="mt-8 space-y-4">
<ol className="space-y-3 rounded-2xl border border-white/10 bg-black/20 p-5 text-sm leading-6 text-slate-300">
<li><span className="mr-2 text-sky-300">1.</span>Open the <a href={sqlEditorUrl} target="_blank" rel="noreferrer" className="font-medium text-sky-300 hover:text-sky-200">Supabase SQL Editor</a>.</li>
<li><span className="mr-2 text-sky-300">2.</span>Copy all of <code className="text-sky-300">supabase/migrations/ALL_MIGRATIONS.sql</code> from this project.</li>
<li><span className="mr-2 text-sky-300">3.</span>Paste it into a new query and select <strong className="text-white">Run</strong>.</li>
</ol>
<p className="text-sm text-slate-400">
Reload this page after the query succeeds. Migration 17 creates a workspace for accounts that already exist.
</p>
</div>
) : (
<SetupWorkspaceForm defaultName={`${user.email?.split("@")[0] || "My"}'s Workspace`} />
)}
</div>
</main>
);
}
57 changes: 57 additions & 0 deletions app/setup/setup-workspace-form.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(null);
const [loading, setLoading] = useState(false);

async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
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 (
<form onSubmit={handleSubmit} className="mt-8 space-y-4">
<div>
<label htmlFor="workspace-name" className="mb-2 block text-sm font-medium text-slate-200">
Workspace name
</label>
<input
id="workspace-name"
value={name}
onChange={(event) => 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"
/>
</div>

{error && <p className="text-sm text-rose-300">{error}</p>}

<button
type="submit"
disabled={loading || !name.trim()}
className="flex w-full items-center justify-center gap-2 rounded-xl bg-sky-400 px-4 py-3 font-semibold text-slate-950 transition hover:bg-sky-300 disabled:cursor-not-allowed disabled:opacity-60"
>
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <ArrowRight className="h-4 w-4" />}
{loading ? "Creating workspace..." : "Continue to dashboard"}
</button>
</form>
);
}
33 changes: 33 additions & 0 deletions compose.hostinger.yaml
Original file line number Diff line number Diff line change
@@ -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:
- "127.0.0.1:${HOST_PORT:-3100}: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
Loading