Compare commits
11 Commits
feature/cl
...
feature/cl
| Author | SHA1 | Date | |
|---|---|---|---|
| 820b676bd8 | |||
| cf1cd39c54 | |||
| 4a60c7ec39 | |||
| b8d2693c67 | |||
| f4ff058a8f | |||
| 7378b09302 | |||
| ad9b6ef7d2 | |||
| fa97ae0400 | |||
| d0a0a81a31 | |||
| 627a34728e | |||
| 826e7da895 |
13
.dockerignore
Normal file
13
.dockerignore
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.git
|
||||||
|
*.log
|
||||||
|
logs
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
20
.env.example
20
.env.example
@@ -1,14 +1,22 @@
|
|||||||
NEXT_PUBLIC_SUPABASE_URL=
|
NODE_ENV=production
|
||||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=
|
|
||||||
SUPABASE_SERVICE_ROLE_KEY=
|
|
||||||
|
|
||||||
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||||
JOB_SECRET=
|
|
||||||
CRON_SECRET=
|
|
||||||
|
|
||||||
ENCRYPTION_KEY=
|
NEXT_PUBLIC_SUPABASE_URL=
|
||||||
|
NEXT_PUBLIC_SUPABASE_ANON_KEY=
|
||||||
|
SUPABASE_URL=
|
||||||
|
SUPABASE_ANON_KEY=
|
||||||
|
SUPABASE_SERVICE_ROLE_KEY=
|
||||||
|
|
||||||
PLESK_CRED_ENC_KEY=
|
PLESK_CRED_ENC_KEY=
|
||||||
|
|
||||||
STRIPE_SECRET_KEY=
|
STRIPE_SECRET_KEY=
|
||||||
STRIPE_WEBHOOK_SECRET=
|
STRIPE_WEBHOOK_SECRET=
|
||||||
|
|
||||||
|
CRON_SHARED_SECRET=
|
||||||
|
|
||||||
|
# Existing runtime keys used by current codebase
|
||||||
|
JOB_SECRET=
|
||||||
|
CRON_SECRET=
|
||||||
|
ENCRYPTION_KEY=
|
||||||
STRIPE_PRICE_ID=
|
STRIPE_PRICE_ID=
|
||||||
|
|||||||
32
Dockerfile
Normal file
32
Dockerfile
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
FROM cgr.dev/chainguard/node:20-dev AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
FROM cgr.dev/chainguard/node:20-dev AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM cgr.dev/chainguard/node:20-dev AS prod-deps
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci --omit=dev
|
||||||
|
|
||||||
|
FROM cgr.dev/chainguard/node:20 AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
COPY --from=prod-deps /app/node_modules ./node_modules
|
||||||
|
COPY --from=builder /app/package.json ./package.json
|
||||||
|
COPY --from=builder /app/next.config.mjs ./next.config.mjs
|
||||||
|
COPY --from=builder /app/.next ./.next
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["node", "node_modules/next/dist/bin/next", "start", "-H", "0.0.0.0", "-p", "3000"]
|
||||||
13
app/api/health/route.ts
Normal file
13
app/api/health/route.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
ok: true,
|
||||||
|
service: "plesk-agency-portal",
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
environment: process.env.NODE_ENV ?? "development",
|
||||||
|
},
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { SignOutButton } from "@/components/auth/sign-out-button";
|
import { SignOutButton } from "@/components/auth/sign-out-button";
|
||||||
import { DashboardControls } from "@/components/dashboard/dashboard-controls";
|
import { DashboardControls } from "@/components/dashboard/dashboard-controls";
|
||||||
import { getServerAgencyContextOrRedirect } from "@/lib/agency";
|
import { getServerAgencyContextOrRedirect } from "@/lib/agency";
|
||||||
|
import { formatDateTime } from "@/lib/dates";
|
||||||
import type { Database } from "@/lib/types";
|
import type { Database } from "@/lib/types";
|
||||||
import { createSupabaseServerClient } from "@/lib/supabase/server";
|
import { createSupabaseServerClient } from "@/lib/supabase/server";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@@ -17,6 +18,10 @@ type ActionLogSummary = Pick<
|
|||||||
Database["public"]["Tables"]["actions_log"]["Row"],
|
Database["public"]["Tables"]["actions_log"]["Row"],
|
||||||
"target_id" | "status" | "action" | "created_at" | "error_message"
|
"target_id" | "status" | "action" | "created_at" | "error_message"
|
||||||
>;
|
>;
|
||||||
|
type AutosyncLatestLogSummary = Pick<
|
||||||
|
Database["public"]["Tables"]["actions_log"]["Row"],
|
||||||
|
"created_at"
|
||||||
|
>;
|
||||||
type HealthLogSummary = Pick<
|
type HealthLogSummary = Pick<
|
||||||
Database["public"]["Tables"]["actions_log"]["Row"],
|
Database["public"]["Tables"]["actions_log"]["Row"],
|
||||||
| "id"
|
| "id"
|
||||||
@@ -28,6 +33,64 @@ type HealthLogSummary = Pick<
|
|||||||
| "metadata"
|
| "metadata"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
const RELATIVE_TIME_FORMATTER = new Intl.RelativeTimeFormat("en", {
|
||||||
|
numeric: "auto",
|
||||||
|
});
|
||||||
|
|
||||||
|
function getLatestTimestamp(values: Array<string | null | undefined>) {
|
||||||
|
let latestIso: string | null = null;
|
||||||
|
let latestMs = Number.NEGATIVE_INFINITY;
|
||||||
|
|
||||||
|
for (const value of values) {
|
||||||
|
if (!value) continue;
|
||||||
|
const parsed = new Date(value).getTime();
|
||||||
|
if (Number.isNaN(parsed)) continue;
|
||||||
|
if (parsed > latestMs) {
|
||||||
|
latestMs = parsed;
|
||||||
|
latestIso = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return latestIso;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRelativeTime(value?: string | null) {
|
||||||
|
if (!value) return null;
|
||||||
|
|
||||||
|
const targetMs = new Date(value).getTime();
|
||||||
|
if (Number.isNaN(targetMs)) return null;
|
||||||
|
|
||||||
|
const deltaSeconds = Math.round((targetMs - Date.now()) / 1000);
|
||||||
|
const absSeconds = Math.abs(deltaSeconds);
|
||||||
|
|
||||||
|
if (absSeconds < 60) {
|
||||||
|
return RELATIVE_TIME_FORMATTER.format(deltaSeconds, "second");
|
||||||
|
}
|
||||||
|
|
||||||
|
const deltaMinutes = Math.round(deltaSeconds / 60);
|
||||||
|
if (Math.abs(deltaMinutes) < 60) {
|
||||||
|
return RELATIVE_TIME_FORMATTER.format(deltaMinutes, "minute");
|
||||||
|
}
|
||||||
|
|
||||||
|
const deltaHours = Math.round(deltaMinutes / 60);
|
||||||
|
if (Math.abs(deltaHours) < 24) {
|
||||||
|
return RELATIVE_TIME_FORMATTER.format(deltaHours, "hour");
|
||||||
|
}
|
||||||
|
|
||||||
|
const deltaDays = Math.round(deltaHours / 24);
|
||||||
|
if (Math.abs(deltaDays) < 30) {
|
||||||
|
return RELATIVE_TIME_FORMATTER.format(deltaDays, "day");
|
||||||
|
}
|
||||||
|
|
||||||
|
const deltaMonths = Math.round(deltaDays / 30);
|
||||||
|
if (Math.abs(deltaMonths) < 12) {
|
||||||
|
return RELATIVE_TIME_FORMATTER.format(deltaMonths, "month");
|
||||||
|
}
|
||||||
|
|
||||||
|
const deltaYears = Math.round(deltaMonths / 12);
|
||||||
|
return RELATIVE_TIME_FORMATTER.format(deltaYears, "year");
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeDomainLike(value: string) {
|
function normalizeDomainLike(value: string) {
|
||||||
return value.trim().toLowerCase().replace(/\.$/, "");
|
return value.trim().toLowerCase().replace(/\.$/, "");
|
||||||
}
|
}
|
||||||
@@ -59,6 +122,7 @@ export default async function DashboardPage() {
|
|||||||
{ data: domains },
|
{ data: domains },
|
||||||
{ data: billing },
|
{ data: billing },
|
||||||
{ data: autoSyncLogs },
|
{ data: autoSyncLogs },
|
||||||
|
{ data: latestAutoSyncLog },
|
||||||
{ data: healthLogs },
|
{ data: healthLogs },
|
||||||
] = (await Promise.all([
|
] = (await Promise.all([
|
||||||
supabase
|
supabase
|
||||||
@@ -100,6 +164,16 @@ export default async function DashboardPage() {
|
|||||||
.eq("action", "plesk_sync_auto")
|
.eq("action", "plesk_sync_auto")
|
||||||
.order("created_at", { ascending: false })
|
.order("created_at", { ascending: false })
|
||||||
.limit(200),
|
.limit(200),
|
||||||
|
supabase
|
||||||
|
.from("actions_log")
|
||||||
|
.select("created_at")
|
||||||
|
.eq("agency_id", context.agencyId)
|
||||||
|
.eq("target_type", "plesk_instance")
|
||||||
|
.eq("action", "autosync_completed")
|
||||||
|
.eq("status", "success")
|
||||||
|
.order("created_at", { ascending: false })
|
||||||
|
.limit(1)
|
||||||
|
.maybeSingle(),
|
||||||
supabase
|
supabase
|
||||||
.from("actions_log")
|
.from("actions_log")
|
||||||
.select(
|
.select(
|
||||||
@@ -173,6 +247,7 @@ export default async function DashboardPage() {
|
|||||||
},
|
},
|
||||||
{ data: BillingSummary | null },
|
{ data: BillingSummary | null },
|
||||||
{ data: ActionLogSummary[] | null },
|
{ data: ActionLogSummary[] | null },
|
||||||
|
{ data: AutosyncLatestLogSummary | null },
|
||||||
{ data: HealthLogSummary[] | null },
|
{ data: HealthLogSummary[] | null },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -293,6 +368,40 @@ export default async function DashboardPage() {
|
|||||||
healthEventsByInstance.set(log.target_id, existing);
|
healthEventsByInstance.set(log.target_id, existing);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const healthCounts = { ok: 0, degraded: 0, down: 0, unknown: 0 };
|
||||||
|
|
||||||
|
for (const instance of instances ?? []) {
|
||||||
|
const status = (instance.health_status ?? "unknown") as
|
||||||
|
| "ok"
|
||||||
|
| "degraded"
|
||||||
|
| "down"
|
||||||
|
| "unknown";
|
||||||
|
if (status === "ok") healthCounts.ok += 1;
|
||||||
|
else if (status === "degraded") healthCounts.degraded += 1;
|
||||||
|
else if (status === "down") healthCounts.down += 1;
|
||||||
|
else healthCounts.unknown += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestHealthCheckAt = getLatestTimestamp(
|
||||||
|
(instances ?? []).map((instance) => instance.health_last_checked_at),
|
||||||
|
);
|
||||||
|
|
||||||
|
const autoSyncEnabledCount = (instances ?? []).filter(
|
||||||
|
(instance) => instance.auto_sync_enabled,
|
||||||
|
).length;
|
||||||
|
|
||||||
|
const latestAutoSyncFromInstances = getLatestTimestamp(
|
||||||
|
(instances ?? []).map((instance) => instance.last_auto_sync_at),
|
||||||
|
);
|
||||||
|
|
||||||
|
const latestAutoSyncAt =
|
||||||
|
latestAutoSyncFromInstances ?? latestAutoSyncLog?.created_at ?? null;
|
||||||
|
|
||||||
|
const downInstanceNames = (instances ?? [])
|
||||||
|
.filter((instance) => instance.health_status === "down")
|
||||||
|
.slice(0, 3)
|
||||||
|
.map((instance) => instance.name || instance.base_url);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto flex w-full max-w-6xl flex-col gap-6 px-6 py-8">
|
<main className="mx-auto flex w-full max-w-6xl flex-col gap-6 px-6 py-8">
|
||||||
<header className="flex items-center justify-between">
|
<header className="flex items-center justify-between">
|
||||||
@@ -313,6 +422,89 @@ export default async function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<section className="rounded-xl border border-slate-200 bg-white p-4">
|
||||||
|
<div className="space-y-3 text-sm text-slate-700">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||||
|
Instance Health
|
||||||
|
</p>
|
||||||
|
<div className="mt-1 flex flex-wrap gap-2">
|
||||||
|
<span className="rounded bg-emerald-100 px-2 py-0.5 text-xs font-medium text-emerald-700">
|
||||||
|
Ok: {healthCounts.ok}
|
||||||
|
</span>
|
||||||
|
<span className="rounded bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-700">
|
||||||
|
Degraded: {healthCounts.degraded}
|
||||||
|
</span>
|
||||||
|
<span className="rounded bg-rose-100 px-2 py-0.5 text-xs font-medium text-rose-700">
|
||||||
|
Down: {healthCounts.down}
|
||||||
|
</span>
|
||||||
|
<span className="rounded bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-700">
|
||||||
|
Unknown: {healthCounts.unknown}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{healthCounts.down > 0 ? (
|
||||||
|
<div className="rounded border border-rose-200 bg-rose-50 px-3 py-2 text-xs text-rose-700">
|
||||||
|
{healthCounts.down} instance{healthCounts.down === 1 ? "" : "s"}{" "}
|
||||||
|
down
|
||||||
|
{downInstanceNames.length > 0
|
||||||
|
? `: ${downInstanceNames.join(", ")}${healthCounts.down > downInstanceNames.length ? ", …" : ""}`
|
||||||
|
: ""}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<p>
|
||||||
|
<span className="font-medium text-slate-900">
|
||||||
|
Last Health Check:
|
||||||
|
</span>{" "}
|
||||||
|
<span
|
||||||
|
title={
|
||||||
|
latestHealthCheckAt
|
||||||
|
? formatDateTime(latestHealthCheckAt)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{formatRelativeTime(latestHealthCheckAt) ?? "Never"}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<span className="font-medium text-slate-900">Auto Sync:</span>{" "}
|
||||||
|
Enabled on {autoSyncEnabledCount} instance
|
||||||
|
{autoSyncEnabledCount === 1 ? "" : "s"}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<span className="font-medium text-slate-900">Last run:</span>{" "}
|
||||||
|
<span
|
||||||
|
title={
|
||||||
|
latestAutoSyncAt
|
||||||
|
? formatDateTime(latestAutoSyncAt)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{formatRelativeTime(latestAutoSyncAt) ?? "Not yet run"}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2 pt-1">
|
||||||
|
<Link
|
||||||
|
href="/dashboard#instances-panel"
|
||||||
|
className="rounded border border-slate-300 px-3 py-1.5 text-xs font-medium text-slate-700"
|
||||||
|
>
|
||||||
|
Instances
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/dashboard/activity"
|
||||||
|
className="rounded border border-slate-300 px-3 py-1.5 text-xs font-medium text-slate-700"
|
||||||
|
>
|
||||||
|
Activity
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="grid gap-4 md:grid-cols-3">
|
<section className="grid gap-4 md:grid-cols-3">
|
||||||
<div className="rounded-xl border border-slate-200 bg-white p-4">
|
<div className="rounded-xl border border-slate-200 bg-white p-4">
|
||||||
<p className="text-sm text-slate-500">Plesk Instances</p>
|
<p className="text-sm text-slate-500">Plesk Instances</p>
|
||||||
@@ -338,47 +530,49 @@ export default async function DashboardPage() {
|
|||||||
Instances query failed: {instancesError.message}
|
Instances query failed: {instancesError.message}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<DashboardControls
|
<section id="instances-panel">
|
||||||
instances={instances ?? []}
|
<DashboardControls
|
||||||
subscriptions={subscriptions ?? []}
|
instances={instances ?? []}
|
||||||
domains={(domains ?? []).map((domain) => {
|
subscriptions={subscriptions ?? []}
|
||||||
const joinedStatusByLocalId = domain.subscription_id
|
domains={(domains ?? []).map((domain) => {
|
||||||
? subscriptionStatusById.get(domain.subscription_id)
|
const joinedStatusByLocalId = domain.subscription_id
|
||||||
: null;
|
? subscriptionStatusById.get(domain.subscription_id)
|
||||||
const joinedStatusByExternalId = domain.external_subscription_id
|
: null;
|
||||||
? subscriptionStatusByExternalId.get(
|
const joinedStatusByExternalId = domain.external_subscription_id
|
||||||
`${domain.plesk_instance_id}:${domain.external_subscription_id}`,
|
? subscriptionStatusByExternalId.get(
|
||||||
)
|
`${domain.plesk_instance_id}:${domain.external_subscription_id}`,
|
||||||
: null;
|
)
|
||||||
const joinedStatusByName = subscriptionStatusByInstanceAndName.get(
|
: null;
|
||||||
`${domain.plesk_instance_id}:${normalizeDomainLike(domain.domain_name)}`,
|
const joinedStatusByName = subscriptionStatusByInstanceAndName.get(
|
||||||
);
|
`${domain.plesk_instance_id}:${normalizeDomainLike(domain.domain_name)}`,
|
||||||
const joinedStatusBySuffix = findBestSuffixStatusMatch(
|
);
|
||||||
normalizeDomainLike(domain.domain_name),
|
const joinedStatusBySuffix = findBestSuffixStatusMatch(
|
||||||
subscriptionStatusNameCandidatesByInstance.get(
|
normalizeDomainLike(domain.domain_name),
|
||||||
domain.plesk_instance_id,
|
subscriptionStatusNameCandidatesByInstance.get(
|
||||||
) ?? [],
|
domain.plesk_instance_id,
|
||||||
);
|
) ?? [],
|
||||||
const joinedStatus =
|
);
|
||||||
joinedStatusByLocalId ??
|
const joinedStatus =
|
||||||
joinedStatusByExternalId ??
|
joinedStatusByLocalId ??
|
||||||
joinedStatusByName ??
|
joinedStatusByExternalId ??
|
||||||
joinedStatusBySuffix;
|
joinedStatusByName ??
|
||||||
|
joinedStatusBySuffix;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...domain,
|
...domain,
|
||||||
status: joinedStatus ?? "unknown",
|
status: joinedStatus ?? "unknown",
|
||||||
subscription_name: domain.subscription_id
|
subscription_name: domain.subscription_id
|
||||||
? (subscriptionDisplayNameById.get(domain.subscription_id) ??
|
? (subscriptionDisplayNameById.get(domain.subscription_id) ??
|
||||||
null)
|
null)
|
||||||
: null,
|
: null,
|
||||||
};
|
};
|
||||||
})}
|
})}
|
||||||
autoSyncByInstance={Object.fromEntries(autoSyncByInstance.entries())}
|
autoSyncByInstance={Object.fromEntries(autoSyncByInstance.entries())}
|
||||||
healthEventsByInstance={Object.fromEntries(
|
healthEventsByInstance={Object.fromEntries(
|
||||||
healthEventsByInstance.entries(),
|
healthEventsByInstance.entries(),
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
23
docker-compose.prod.yml
Normal file
23
docker-compose.prod.yml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
services:
|
||||||
|
web:
|
||||||
|
container_name: plesk-agency-portal
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
[
|
||||||
|
"CMD",
|
||||||
|
"node",
|
||||||
|
"-e",
|
||||||
|
"fetch('http://127.0.0.1:3000/api/health').then((r)=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))",
|
||||||
|
]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 20s
|
||||||
58
docs/deployment-production.md
Normal file
58
docs/deployment-production.md
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# Production Deployment Runtime Foundation
|
||||||
|
|
||||||
|
This project can be deployed in a dedicated Proxmox container using Docker Compose. The files added here establish a minimal production runtime baseline without changing application business logic or Supabase security boundaries.
|
||||||
|
|
||||||
|
## Dockerfile purpose
|
||||||
|
|
||||||
|
The `Dockerfile` uses a multi-stage build on Node 20:
|
||||||
|
|
||||||
|
- installs dependencies
|
||||||
|
- runs `next build`
|
||||||
|
- prepares a lean runtime image with production dependencies
|
||||||
|
- runs the app with `next start` on port `3000`
|
||||||
|
|
||||||
|
This keeps the runtime image small and focused on serving the built Next.js app.
|
||||||
|
|
||||||
|
## docker-compose.prod.yml purpose
|
||||||
|
|
||||||
|
`docker-compose.prod.yml` defines one service:
|
||||||
|
|
||||||
|
- service name: `web`
|
||||||
|
- container name: `plesk-agency-portal`
|
||||||
|
- restart policy: `unless-stopped`
|
||||||
|
- environment from `.env`
|
||||||
|
- port mapping `3000:3000`
|
||||||
|
- health check against `/api/health`
|
||||||
|
|
||||||
|
## External Supabase requirement
|
||||||
|
|
||||||
|
Supabase remains external to this application container. The app container must only connect to Supabase via environment variables. No local Supabase container is included in this production compose file.
|
||||||
|
|
||||||
|
## Health endpoint usage
|
||||||
|
|
||||||
|
`GET /api/health` provides a fast, dependency-light runtime check for container orchestration and uptime checks.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s http://localhost:3000/api/health
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected response shape:
|
||||||
|
|
||||||
|
- `ok: true`
|
||||||
|
- `service: "plesk-agency-portal"`
|
||||||
|
- `timestamp`
|
||||||
|
- `environment`
|
||||||
|
|
||||||
|
## Production environment expectations
|
||||||
|
|
||||||
|
Before deploying, set production-safe values in `.env` (using `.env.example` as the template), including:
|
||||||
|
|
||||||
|
- app URL and `NODE_ENV`
|
||||||
|
- Supabase public and server credentials
|
||||||
|
- Plesk credential encryption key
|
||||||
|
- Stripe secrets
|
||||||
|
- cron/job shared secrets
|
||||||
|
|
||||||
|
Keep secrets out of source control.
|
||||||
Reference in New Issue
Block a user