create-nextblock 0.8.11 → 0.9.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/create-nextblock.js +101 -35
- package/docker-template/.dockerignore +23 -0
- package/docker-template/.env.docker.example +56 -0
- package/docker-template/Dockerfile +85 -0
- package/docker-template/docker/db/init/99-jwt.sql +6 -0
- package/docker-template/docker/db/init/99-roles.sql +25 -0
- package/docker-template/docker/kong/kong.yml +112 -0
- package/docker-template/docker/migrate/run-migrations.sh +51 -0
- package/docker-template/docker-compose.yml +219 -0
- package/docker-template/scripts/docker-setup.mjs +242 -0
- package/package.json +1 -1
- package/scripts/sync-template.js +29 -0
- package/templates/nextblock-template/.dockerignore +23 -0
- package/templates/nextblock-template/Dockerfile +85 -0
- package/templates/nextblock-template/app/[slug]/page.tsx +5 -0
- package/templates/nextblock-template/app/actions.ts +58 -8
- package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +83 -0
- package/templates/nextblock-template/app/api/upload/presigned-url/route.ts +9 -9
- package/templates/nextblock-template/app/article/[slug]/page.tsx +5 -0
- package/templates/nextblock-template/app/cms/settings/security/actions.ts +30 -0
- package/templates/nextblock-template/app/cms/settings/security/components/SecurityPanel.tsx +69 -0
- package/templates/nextblock-template/app/layout.tsx +57 -3
- package/templates/nextblock-template/app/lib/site-settings.ts +22 -7
- package/templates/nextblock-template/app/page.tsx +6 -0
- package/templates/nextblock-template/app/product/[slug]/page.tsx +5 -0
- package/templates/nextblock-template/app/setup/SetupWizard.tsx +771 -0
- package/templates/nextblock-template/app/setup/layout.tsx +13 -0
- package/templates/nextblock-template/app/setup/page.tsx +103 -0
- package/templates/nextblock-template/components/AppShell.tsx +12 -0
- package/templates/nextblock-template/components/header-auth.tsx +24 -62
- package/templates/nextblock-template/docker/db/init/99-jwt.sql +6 -0
- package/templates/nextblock-template/docker/db/init/99-roles.sql +25 -0
- package/templates/nextblock-template/docker/kong/kong.yml +112 -0
- package/templates/nextblock-template/docker/migrate/run-migrations.sh +51 -0
- package/templates/nextblock-template/docker-compose.yml +219 -0
- package/templates/nextblock-template/docs/11-SELF-HOSTED-DOCKER.md +173 -0
- package/templates/nextblock-template/docs/12-VERCEL-DEPLOYMENT.md +67 -0
- package/templates/nextblock-template/docs/README.md +2 -0
- package/templates/nextblock-template/lib/blocks/FeaturedProductBlock.tsx +1 -1
- package/templates/nextblock-template/lib/blocks/ProductGridBlock.tsx +1 -1
- package/templates/nextblock-template/lib/custom-block-r2-upload.test.ts +5 -5
- package/templates/nextblock-template/lib/custom-block-r2-upload.ts +2 -2
- package/templates/nextblock-template/lib/setup/actions.ts +370 -0
- package/templates/nextblock-template/lib/setup/env-status.ts +86 -0
- package/templates/nextblock-template/lib/setup/env-write.ts +111 -0
- package/templates/nextblock-template/lib/setup/provisioning.ts +59 -0
- package/templates/nextblock-template/lib/setup/schema-apply.ts +379 -0
- package/templates/nextblock-template/lib/setup/system-config.ts +105 -0
- package/templates/nextblock-template/lib/setup/types.ts +18 -0
- package/templates/nextblock-template/next.config.js +9 -0
- package/templates/nextblock-template/package.json +6 -2
- package/templates/nextblock-template/proxy.ts +143 -49
- package/templates/nextblock-template/scripts/docker-setup.mjs +242 -0
- package/templates/nextblock-template/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Minimal layout for the first-boot wizard. Deliberately does NO Supabase work so it
|
|
2
|
+
// renders even on a completely unconfigured instance. (It is still nested inside the
|
|
3
|
+
// root layout, which short-circuits its own data loading when unconfigured.)
|
|
4
|
+
import type { ReactNode } from 'react';
|
|
5
|
+
|
|
6
|
+
export const metadata = {
|
|
7
|
+
title: 'Set up NextBlock',
|
|
8
|
+
robots: { index: false, follow: false },
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export default function SetupLayout({ children }: { children: ReactNode }) {
|
|
12
|
+
return <div className="mx-auto w-full max-w-3xl px-4 py-10 sm:py-16">{children}</div>;
|
|
13
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { redirect } from 'next/navigation';
|
|
2
|
+
import { createClient } from '@nextblock-cms/db/server';
|
|
3
|
+
import {
|
|
4
|
+
detectChannel,
|
|
5
|
+
isLocalWritableEnv,
|
|
6
|
+
isSupabaseConfigured,
|
|
7
|
+
type DeployChannel,
|
|
8
|
+
} from '../../lib/setup/env-status';
|
|
9
|
+
import { getProvisioningStatus } from '../../lib/setup/provisioning';
|
|
10
|
+
import SetupWizard, { type StoragePrefill } from './SetupWizard';
|
|
11
|
+
|
|
12
|
+
// Always evaluate live: the wizard's whole job is to react to the current env state.
|
|
13
|
+
export const dynamic = 'force-dynamic';
|
|
14
|
+
|
|
15
|
+
function buildStoragePrefill(channel: DeployChannel, supabaseUrl: string): StoragePrefill {
|
|
16
|
+
if (channel === 'docker') {
|
|
17
|
+
// The Docker stack ships MinIO as the S3 backend; docker-setup wrote these already.
|
|
18
|
+
return {
|
|
19
|
+
kind: 'minio',
|
|
20
|
+
readOnly: true,
|
|
21
|
+
accountId: process.env.R2_ACCOUNT_ID ?? 'minio',
|
|
22
|
+
bucket: process.env.STORAGE_BUCKET ?? process.env.R2_BUCKET_NAME ?? 'media',
|
|
23
|
+
endpoint: process.env.R2_S3_ENDPOINT ?? 'http://minio:9000',
|
|
24
|
+
publicUrl: process.env.NEXT_PUBLIC_R2_PUBLIC_URL ?? 'http://localhost:9000',
|
|
25
|
+
baseUrl: process.env.NEXT_PUBLIC_R2_BASE_URL ?? '',
|
|
26
|
+
accessKeyId: '',
|
|
27
|
+
secretAccessKey: '',
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (channel === 'vercel') {
|
|
32
|
+
// One-click Vercel deploys connect to Supabase Storage's S3-compatible endpoint.
|
|
33
|
+
const base = supabaseUrl.replace(/\/$/, '');
|
|
34
|
+
return {
|
|
35
|
+
kind: 'supabase',
|
|
36
|
+
readOnly: false,
|
|
37
|
+
accountId: 'supabase',
|
|
38
|
+
bucket: 'media',
|
|
39
|
+
endpoint: base ? `${base}/storage/v1/s3` : '',
|
|
40
|
+
publicUrl: base ? `${base}/storage/v1/object/public` : '',
|
|
41
|
+
baseUrl: '',
|
|
42
|
+
accessKeyId: process.env.R2_ACCESS_KEY_ID ?? '',
|
|
43
|
+
secretAccessKey: '',
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Local: the user brings their own Cloudflare R2 (higher free-tier storage).
|
|
48
|
+
return {
|
|
49
|
+
kind: 'r2',
|
|
50
|
+
readOnly: false,
|
|
51
|
+
accountId: process.env.R2_ACCOUNT_ID ?? '',
|
|
52
|
+
bucket: process.env.R2_BUCKET_NAME ?? '',
|
|
53
|
+
endpoint: '',
|
|
54
|
+
publicUrl: process.env.NEXT_PUBLIC_R2_PUBLIC_URL ?? '',
|
|
55
|
+
baseUrl: process.env.NEXT_PUBLIC_R2_BASE_URL ?? '',
|
|
56
|
+
accessKeyId: process.env.R2_ACCESS_KEY_ID ?? '',
|
|
57
|
+
secretAccessKey: '',
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export default async function SetupPage() {
|
|
62
|
+
const status = await getProvisioningStatus();
|
|
63
|
+
|
|
64
|
+
// Already finished — there's a first admin. Get out of the wizard.
|
|
65
|
+
if (status.hasAdmin) {
|
|
66
|
+
redirect('/cms/dashboard');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// A signed-in user never needs the wizard. This also closes the brief window right
|
|
70
|
+
// after completion where is_admin_created may still be read-stale: the just-created
|
|
71
|
+
// admin has a session, so send them straight to the CMS. (Only checked when
|
|
72
|
+
// configured — an unconfigured instance has no real auth.)
|
|
73
|
+
if (status.configured) {
|
|
74
|
+
const supabase = createClient();
|
|
75
|
+
const {
|
|
76
|
+
data: { user },
|
|
77
|
+
} = await supabase.auth.getUser();
|
|
78
|
+
if (user) {
|
|
79
|
+
redirect('/cms/dashboard');
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const channel = detectChannel();
|
|
84
|
+
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL ?? '';
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<SetupWizard
|
|
88
|
+
channel={channel}
|
|
89
|
+
configured={isSupabaseConfigured()}
|
|
90
|
+
writable={isLocalWritableEnv()}
|
|
91
|
+
siteUrl={process.env.NEXT_PUBLIC_URL ?? ''}
|
|
92
|
+
storagePrefill={buildStoragePrefill(channel, supabaseUrl)}
|
|
93
|
+
smtpPrefill={{
|
|
94
|
+
host: process.env.SMTP_HOST ?? '',
|
|
95
|
+
port: process.env.SMTP_PORT ?? '465',
|
|
96
|
+
user: process.env.SMTP_USER ?? '',
|
|
97
|
+
fromEmail: process.env.SMTP_FROM_EMAIL ?? '',
|
|
98
|
+
fromName: process.env.SMTP_FROM_NAME ?? '',
|
|
99
|
+
}}
|
|
100
|
+
turnstilePrefill={{ siteKey: process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY ?? '' }}
|
|
101
|
+
/>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
@@ -66,8 +66,20 @@ export function AppShell({
|
|
|
66
66
|
}: AppShellProps) {
|
|
67
67
|
const pathname = usePathname() || '';
|
|
68
68
|
const isCmsRequest = pathname.startsWith('/cms');
|
|
69
|
+
const isSetupRequest = pathname === '/setup' || pathname.startsWith('/setup/');
|
|
69
70
|
const branding = useMemo(() => ({ logo, siteTitle }), [logo, siteTitle]);
|
|
70
71
|
|
|
72
|
+
// The first-boot setup wizard renders on its own clean, chrome-free page: no header
|
|
73
|
+
// or footer, and crucially no EnvVarWarning — the whole point of /setup is to supply
|
|
74
|
+
// the very env vars that warning complains about.
|
|
75
|
+
if (isSetupRequest) {
|
|
76
|
+
return (
|
|
77
|
+
<AppBrandingContext.Provider value={branding}>
|
|
78
|
+
<main className="min-h-screen bg-background">{children}</main>
|
|
79
|
+
</AppBrandingContext.Provider>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
71
83
|
return (
|
|
72
84
|
<AppBrandingContext.Provider value={branding}>
|
|
73
85
|
{process.env.NEXT_PUBLIC_IS_SANDBOX === 'true' && !isCmsRequest && <SandboxBanner />}
|
|
@@ -1,70 +1,32 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import
|
|
4
|
-
import
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
DropdownMenuTrigger,
|
|
19
|
-
} from "@nextblock-cms/ui";
|
|
3
|
+
import Link from "next/link";
|
|
4
|
+
import { Avatar, AvatarFallback, AvatarImage } from "@nextblock-cms/ui";
|
|
5
|
+
import { Button } from "@nextblock-cms/ui";
|
|
6
|
+
import { signOutAction } from "../app/actions";
|
|
7
|
+
import { useAuth } from "../context/AuthContext";
|
|
8
|
+
import { useTranslations } from "@nextblock-cms/utils";
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
DropdownMenu,
|
|
12
|
+
DropdownMenuContent,
|
|
13
|
+
DropdownMenuItem,
|
|
14
|
+
DropdownMenuLabel,
|
|
15
|
+
DropdownMenuSeparator,
|
|
16
|
+
DropdownMenuTrigger,
|
|
17
|
+
} from "@nextblock-cms/ui";
|
|
20
18
|
import { User, LogOut, LayoutDashboard } from "lucide-react";
|
|
21
19
|
|
|
22
|
-
export default function AuthButton() {
|
|
23
|
-
const { user, profile, isAdmin, isWriter } = useAuth();
|
|
24
|
-
const { t } = useTranslations();
|
|
25
|
-
const displayName = profile?.full_name || profile?.github_username || user?.email || null;
|
|
26
|
-
const showAdminLink = isAdmin || isWriter;
|
|
27
|
-
|
|
28
|
-
const handleSignOut = async () => {
|
|
29
|
-
await signOutAction();
|
|
30
|
-
};
|
|
20
|
+
export default function AuthButton() {
|
|
21
|
+
const { user, profile, isAdmin, isWriter } = useAuth();
|
|
22
|
+
const { t } = useTranslations();
|
|
23
|
+
const displayName = profile?.full_name || profile?.github_username || user?.email || null;
|
|
24
|
+
const showAdminLink = isAdmin || isWriter;
|
|
25
|
+
|
|
26
|
+
const handleSignOut = async () => {
|
|
27
|
+
await signOutAction();
|
|
28
|
+
};
|
|
31
29
|
|
|
32
|
-
if (!hasPublicEnvVars) {
|
|
33
|
-
return (
|
|
34
|
-
<>
|
|
35
|
-
<div className="flex gap-4 items-center">
|
|
36
|
-
<div>
|
|
37
|
-
<Badge
|
|
38
|
-
variant={"default"}
|
|
39
|
-
className="font-normal pointer-events-none"
|
|
40
|
-
>
|
|
41
|
-
{t('update_env_file_warning')}
|
|
42
|
-
</Badge>
|
|
43
|
-
</div>
|
|
44
|
-
<div className="flex gap-2">
|
|
45
|
-
<Button
|
|
46
|
-
asChild
|
|
47
|
-
size="sm"
|
|
48
|
-
variant={"outline"}
|
|
49
|
-
disabled
|
|
50
|
-
className="opacity-75 cursor-none pointer-events-none"
|
|
51
|
-
>
|
|
52
|
-
<Link href="/sign-in">{t('sign_in')}</Link>
|
|
53
|
-
</Button>
|
|
54
|
-
<Button
|
|
55
|
-
asChild
|
|
56
|
-
size="sm"
|
|
57
|
-
variant={"default"}
|
|
58
|
-
disabled
|
|
59
|
-
className="opacity-75 cursor-none pointer-events-none"
|
|
60
|
-
>
|
|
61
|
-
<Link href="/sign-up">{t('sign_up')}</Link>
|
|
62
|
-
</Button>
|
|
63
|
-
</div>
|
|
64
|
-
</div>
|
|
65
|
-
</>
|
|
66
|
-
);
|
|
67
|
-
}
|
|
68
30
|
return user ? (
|
|
69
31
|
<div className="flex items-center gap-4">
|
|
70
32
|
<DropdownMenu>
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
-- Expose the JWT secret to Postgres as a database GUC, matching Supabase's setup. Runs once.
|
|
2
|
+
\set jwt_secret `echo "$JWT_SECRET"`
|
|
3
|
+
\set jwt_exp `echo "$JWT_EXP"`
|
|
4
|
+
|
|
5
|
+
alter database postgres set "app.settings.jwt_secret" to :'jwt_secret';
|
|
6
|
+
alter database postgres set "app.settings.jwt_exp" to :'jwt_exp';
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
-- Self-hosted Supabase role passwords. The supabase/postgres image creates the platform roles;
|
|
2
|
+
-- GoTrue logs in as supabase_auth_admin and PostgREST as authenticator. Give the roles that exist
|
|
3
|
+
-- the generated POSTGRES_PASSWORD (the set varies by image tag, so each is guarded; the password
|
|
4
|
+
-- is passed via a session GUC because psql variables aren't interpolated inside a dollar block).
|
|
5
|
+
-- Runs once on first init.
|
|
6
|
+
\set pgpass `echo "$POSTGRES_PASSWORD"`
|
|
7
|
+
select set_config('nextblock.pgpass', :'pgpass', false);
|
|
8
|
+
|
|
9
|
+
do $$
|
|
10
|
+
declare
|
|
11
|
+
role_name text;
|
|
12
|
+
begin
|
|
13
|
+
foreach role_name in array array[
|
|
14
|
+
'authenticator',
|
|
15
|
+
'pgbouncer',
|
|
16
|
+
'supabase_auth_admin',
|
|
17
|
+
'supabase_storage_admin',
|
|
18
|
+
'supabase_functions_admin',
|
|
19
|
+
'supabase_read_only_user'
|
|
20
|
+
] loop
|
|
21
|
+
if exists (select 1 from pg_roles where rolname = role_name) then
|
|
22
|
+
execute format('alter role %I with password %L', role_name, current_setting('nextblock.pgpass'));
|
|
23
|
+
end if;
|
|
24
|
+
end loop;
|
|
25
|
+
end $$;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# Kong declarative (DB-less) gateway config for the NextBlock self-hosted stack.
|
|
2
|
+
#
|
|
3
|
+
# $SUPABASE_ANON_KEY and $SUPABASE_SERVICE_KEY are substituted at container start by the kong
|
|
4
|
+
# service entrypoint (docker-compose.yml) from the generated ANON_KEY / SERVICE_ROLE_KEY env.
|
|
5
|
+
# Trimmed to just the Auth (GoTrue) and PostgREST routes the app uses.
|
|
6
|
+
_format_version: '2.1'
|
|
7
|
+
_transform: true
|
|
8
|
+
|
|
9
|
+
consumers:
|
|
10
|
+
- username: anon
|
|
11
|
+
keyauth_credentials:
|
|
12
|
+
- key: $SUPABASE_ANON_KEY
|
|
13
|
+
- username: service_role
|
|
14
|
+
keyauth_credentials:
|
|
15
|
+
- key: $SUPABASE_SERVICE_KEY
|
|
16
|
+
|
|
17
|
+
acls:
|
|
18
|
+
- consumer: anon
|
|
19
|
+
group: anon
|
|
20
|
+
- consumer: service_role
|
|
21
|
+
group: admin
|
|
22
|
+
|
|
23
|
+
services:
|
|
24
|
+
- name: auth-v1-open
|
|
25
|
+
url: http://auth:9999/verify
|
|
26
|
+
routes:
|
|
27
|
+
- name: auth-v1-open
|
|
28
|
+
strip_path: true
|
|
29
|
+
paths:
|
|
30
|
+
- /auth/v1/verify
|
|
31
|
+
plugins:
|
|
32
|
+
- name: cors
|
|
33
|
+
- name: auth-v1-open-callback
|
|
34
|
+
url: http://auth:9999/callback
|
|
35
|
+
routes:
|
|
36
|
+
- name: auth-v1-open-callback
|
|
37
|
+
strip_path: true
|
|
38
|
+
paths:
|
|
39
|
+
- /auth/v1/callback
|
|
40
|
+
plugins:
|
|
41
|
+
- name: cors
|
|
42
|
+
- name: auth-v1-open-authorize
|
|
43
|
+
url: http://auth:9999/authorize
|
|
44
|
+
routes:
|
|
45
|
+
- name: auth-v1-open-authorize
|
|
46
|
+
strip_path: true
|
|
47
|
+
paths:
|
|
48
|
+
- /auth/v1/authorize
|
|
49
|
+
plugins:
|
|
50
|
+
- name: cors
|
|
51
|
+
|
|
52
|
+
- name: auth-v1
|
|
53
|
+
url: http://auth:9999/
|
|
54
|
+
routes:
|
|
55
|
+
- name: auth-v1-all
|
|
56
|
+
strip_path: true
|
|
57
|
+
paths:
|
|
58
|
+
- /auth/v1/
|
|
59
|
+
plugins:
|
|
60
|
+
- name: cors
|
|
61
|
+
- name: key-auth
|
|
62
|
+
config:
|
|
63
|
+
hide_credentials: false
|
|
64
|
+
- name: acl
|
|
65
|
+
config:
|
|
66
|
+
hide_groups_header: true
|
|
67
|
+
allow:
|
|
68
|
+
- admin
|
|
69
|
+
- anon
|
|
70
|
+
|
|
71
|
+
- name: rest-v1
|
|
72
|
+
url: http://rest:3000/
|
|
73
|
+
routes:
|
|
74
|
+
- name: rest-v1-all
|
|
75
|
+
strip_path: true
|
|
76
|
+
paths:
|
|
77
|
+
- /rest/v1/
|
|
78
|
+
plugins:
|
|
79
|
+
- name: cors
|
|
80
|
+
- name: key-auth
|
|
81
|
+
config:
|
|
82
|
+
hide_credentials: true
|
|
83
|
+
- name: acl
|
|
84
|
+
config:
|
|
85
|
+
hide_groups_header: true
|
|
86
|
+
allow:
|
|
87
|
+
- admin
|
|
88
|
+
- anon
|
|
89
|
+
|
|
90
|
+
- name: graphql-v1
|
|
91
|
+
url: http://rest:3000/rpc/graphql
|
|
92
|
+
routes:
|
|
93
|
+
- name: graphql-v1-all
|
|
94
|
+
strip_path: true
|
|
95
|
+
paths:
|
|
96
|
+
- /graphql/v1
|
|
97
|
+
plugins:
|
|
98
|
+
- name: cors
|
|
99
|
+
- name: key-auth
|
|
100
|
+
config:
|
|
101
|
+
hide_credentials: false
|
|
102
|
+
- name: request-transformer
|
|
103
|
+
config:
|
|
104
|
+
add:
|
|
105
|
+
headers:
|
|
106
|
+
- 'Content-Profile: graphql_public'
|
|
107
|
+
- name: acl
|
|
108
|
+
config:
|
|
109
|
+
hide_groups_header: true
|
|
110
|
+
allow:
|
|
111
|
+
- admin
|
|
112
|
+
- anon
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# Applies the NextBlock SQL migrations (mounted at /migrations) in chronological filename order,
|
|
3
|
+
# once GoTrue has provisioned the auth schema (profiles etc. FK to auth.users). Idempotent: a
|
|
4
|
+
# tracking table records applied versions so restarts never re-run a migration. Each file runs
|
|
5
|
+
# in a single transaction (ON_ERROR_STOP).
|
|
6
|
+
set -eu
|
|
7
|
+
|
|
8
|
+
PGHOST="${POSTGRES_HOST:-db}"
|
|
9
|
+
PGPORT="${POSTGRES_PORT:-5432}"
|
|
10
|
+
PGUSER="postgres"
|
|
11
|
+
PGDATABASE="${POSTGRES_DB:-postgres}"
|
|
12
|
+
export PGPASSWORD="${POSTGRES_PASSWORD}"
|
|
13
|
+
|
|
14
|
+
psql_cmd() {
|
|
15
|
+
psql -v ON_ERROR_STOP=1 -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d "$PGDATABASE" "$@"
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
echo "[migrate] waiting for Postgres at ${PGHOST}:${PGPORT}..."
|
|
19
|
+
until psql_cmd -c 'select 1;' >/dev/null 2>&1; do
|
|
20
|
+
sleep 2
|
|
21
|
+
done
|
|
22
|
+
|
|
23
|
+
echo "[migrate] waiting for GoTrue to create auth.users..."
|
|
24
|
+
until [ "$(psql_cmd -tAc "select to_regclass('auth.users') is not null;")" = "t" ]; do
|
|
25
|
+
sleep 2
|
|
26
|
+
done
|
|
27
|
+
|
|
28
|
+
psql_cmd -c "create table if not exists public._nextblock_docker_migrations (
|
|
29
|
+
version text primary key,
|
|
30
|
+
applied_at timestamptz not null default now()
|
|
31
|
+
);"
|
|
32
|
+
|
|
33
|
+
applied_any=0
|
|
34
|
+
for file in $(ls /migrations/*.sql | sort); do
|
|
35
|
+
version="$(basename "$file" .sql)"
|
|
36
|
+
already="$(psql_cmd -tAc "select 1 from public._nextblock_docker_migrations where version = '${version}';")"
|
|
37
|
+
if [ "$already" = "1" ]; then
|
|
38
|
+
echo "[migrate] skip ${version} (already applied)"
|
|
39
|
+
continue
|
|
40
|
+
fi
|
|
41
|
+
echo "[migrate] applying ${version}"
|
|
42
|
+
psql_cmd --single-transaction -f "$file"
|
|
43
|
+
psql_cmd -c "insert into public._nextblock_docker_migrations (version) values ('${version}');"
|
|
44
|
+
applied_any=1
|
|
45
|
+
done
|
|
46
|
+
|
|
47
|
+
if [ "$applied_any" = "1" ]; then
|
|
48
|
+
echo "[migrate] migrations applied successfully."
|
|
49
|
+
else
|
|
50
|
+
echo "[migrate] database already up to date."
|
|
51
|
+
fi
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
# Self-hosted NextBlock CMS stack for a standalone project. Generated/driven by
|
|
2
|
+
# `npm run docker:setup`, which writes the .env this file reads. Mirrors the production Supabase
|
|
3
|
+
# topology: Postgres + GoTrue + PostgREST behind a Kong gateway, plus MinIO for S3 media.
|
|
4
|
+
name: nextblock
|
|
5
|
+
|
|
6
|
+
services:
|
|
7
|
+
db:
|
|
8
|
+
image: ${SUPABASE_DB_IMAGE:-supabase/postgres:15.8.1.085}
|
|
9
|
+
restart: unless-stopped
|
|
10
|
+
environment:
|
|
11
|
+
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
|
12
|
+
POSTGRES_DB: ${POSTGRES_DB:-postgres}
|
|
13
|
+
JWT_SECRET: ${JWT_SECRET}
|
|
14
|
+
JWT_EXP: ${JWT_EXP:-3600}
|
|
15
|
+
volumes:
|
|
16
|
+
- nextblock_db_store:/var/lib/postgresql/data
|
|
17
|
+
- ./docker/db/init/99-roles.sql:/docker-entrypoint-initdb.d/init-scripts/99-roles.sql:ro
|
|
18
|
+
- ./docker/db/init/99-jwt.sql:/docker-entrypoint-initdb.d/init-scripts/99-jwt.sql:ro
|
|
19
|
+
healthcheck:
|
|
20
|
+
test: ['CMD', 'pg_isready', '-U', 'postgres', '-h', 'localhost']
|
|
21
|
+
interval: 5s
|
|
22
|
+
timeout: 5s
|
|
23
|
+
retries: 12
|
|
24
|
+
# supabase/postgres runs a heavy first-init; give it time before counting health failures.
|
|
25
|
+
start_period: 60s
|
|
26
|
+
ports:
|
|
27
|
+
- '${POSTGRES_PORT_EXTERNAL:-54322}:5432'
|
|
28
|
+
|
|
29
|
+
auth:
|
|
30
|
+
image: ${SUPABASE_GOTRUE_IMAGE:-supabase/gotrue:v2.189.0}
|
|
31
|
+
restart: unless-stopped
|
|
32
|
+
depends_on:
|
|
33
|
+
db:
|
|
34
|
+
condition: service_healthy
|
|
35
|
+
environment:
|
|
36
|
+
GOTRUE_API_HOST: 0.0.0.0
|
|
37
|
+
GOTRUE_API_PORT: 9999
|
|
38
|
+
API_EXTERNAL_URL: ${API_EXTERNAL_URL:-http://localhost:8000}
|
|
39
|
+
GOTRUE_DB_DRIVER: postgres
|
|
40
|
+
GOTRUE_DB_DATABASE_URL: postgres://supabase_auth_admin:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-postgres}
|
|
41
|
+
GOTRUE_SITE_URL: ${SITE_URL:-http://localhost:3000}
|
|
42
|
+
GOTRUE_URI_ALLOW_LIST: '*'
|
|
43
|
+
GOTRUE_DISABLE_SIGNUP: 'false'
|
|
44
|
+
GOTRUE_JWT_ADMIN_ROLES: service_role
|
|
45
|
+
GOTRUE_JWT_AUD: authenticated
|
|
46
|
+
GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated
|
|
47
|
+
GOTRUE_JWT_EXP: ${JWT_EXP:-3600}
|
|
48
|
+
GOTRUE_JWT_SECRET: ${JWT_SECRET}
|
|
49
|
+
GOTRUE_JWT_ISSUER: ${API_EXTERNAL_URL:-http://localhost:8000}/auth/v1
|
|
50
|
+
GOTRUE_EXTERNAL_EMAIL_ENABLED: 'true'
|
|
51
|
+
GOTRUE_MAILER_AUTOCONFIRM: ${GOTRUE_MAILER_AUTOCONFIRM:-true}
|
|
52
|
+
GOTRUE_SMTP_ADMIN_EMAIL: ${SMTP_FROM_EMAIL:-admin@example.com}
|
|
53
|
+
GOTRUE_SMTP_HOST: ${SMTP_HOST:-}
|
|
54
|
+
# Must be a valid integer even when SMTP is skipped (GoTrue parses it as int and crashes on
|
|
55
|
+
# ''). With no host + autoconfirm on, no mail is sent, so the value is otherwise inert.
|
|
56
|
+
GOTRUE_SMTP_PORT: ${SMTP_PORT:-2500}
|
|
57
|
+
GOTRUE_SMTP_USER: ${SMTP_USER:-}
|
|
58
|
+
GOTRUE_SMTP_PASS: ${SMTP_PASS:-}
|
|
59
|
+
GOTRUE_SMTP_SENDER_NAME: ${SMTP_FROM_NAME:-NextBlock}
|
|
60
|
+
GOTRUE_MAILER_URLPATHS_CONFIRMATION: /auth/v1/verify
|
|
61
|
+
GOTRUE_MAILER_URLPATHS_RECOVERY: /auth/v1/verify
|
|
62
|
+
GOTRUE_MAILER_URLPATHS_INVITE: /auth/v1/verify
|
|
63
|
+
GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: /auth/v1/verify
|
|
64
|
+
healthcheck:
|
|
65
|
+
test: ['CMD', 'wget', '--no-verbose', '--tries=1', '--spider', 'http://localhost:9999/health']
|
|
66
|
+
interval: 5s
|
|
67
|
+
timeout: 5s
|
|
68
|
+
retries: 15
|
|
69
|
+
|
|
70
|
+
rest:
|
|
71
|
+
image: ${SUPABASE_POSTGREST_IMAGE:-postgrest/postgrest:v12.2.12}
|
|
72
|
+
restart: unless-stopped
|
|
73
|
+
depends_on:
|
|
74
|
+
db:
|
|
75
|
+
condition: service_healthy
|
|
76
|
+
environment:
|
|
77
|
+
PGRST_DB_URI: postgres://authenticator:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-postgres}
|
|
78
|
+
PGRST_DB_SCHEMAS: public,graphql_public
|
|
79
|
+
PGRST_DB_ANON_ROLE: anon
|
|
80
|
+
PGRST_JWT_SECRET: ${JWT_SECRET}
|
|
81
|
+
PGRST_DB_USE_LEGACY_GUCS: 'false'
|
|
82
|
+
PGRST_APP_SETTINGS_JWT_SECRET: ${JWT_SECRET}
|
|
83
|
+
PGRST_APP_SETTINGS_JWT_EXP: ${JWT_EXP:-3600}
|
|
84
|
+
PGRST_DB_EXTRA_SEARCH_PATH: public,extensions
|
|
85
|
+
|
|
86
|
+
kong:
|
|
87
|
+
image: ${SUPABASE_KONG_IMAGE:-kong:2.8.1}
|
|
88
|
+
restart: unless-stopped
|
|
89
|
+
depends_on:
|
|
90
|
+
auth:
|
|
91
|
+
condition: service_started
|
|
92
|
+
rest:
|
|
93
|
+
condition: service_started
|
|
94
|
+
environment:
|
|
95
|
+
KONG_DATABASE: 'off'
|
|
96
|
+
KONG_DECLARATIVE_CONFIG: /home/kong/kong.yml
|
|
97
|
+
KONG_DNS_ORDER: LAST,A,CNAME
|
|
98
|
+
KONG_PLUGINS: request-transformer,cors,key-auth,acl
|
|
99
|
+
KONG_NGINX_PROXY_PROXY_BUFFER_SIZE: 160k
|
|
100
|
+
KONG_NGINX_PROXY_PROXY_BUFFERS: 64 160k
|
|
101
|
+
# localhost cookies aren't port-scoped, so the app's Supabase auth cookies are also sent to
|
|
102
|
+
# Kong on every supabase-js request — give nginx room so they don't 431.
|
|
103
|
+
KONG_NGINX_HTTP_LARGE_CLIENT_HEADER_BUFFERS: 8 32k
|
|
104
|
+
SUPABASE_ANON_KEY: ${ANON_KEY}
|
|
105
|
+
SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY}
|
|
106
|
+
volumes:
|
|
107
|
+
- ./docker/kong/kong.yml:/home/kong/temp.yml:ro
|
|
108
|
+
entrypoint: bash -c 'eval "echo \"$$(cat ~/temp.yml)\"" > ~/kong.yml && /docker-entrypoint.sh kong docker-start'
|
|
109
|
+
ports:
|
|
110
|
+
- '${KONG_HTTP_PORT:-8000}:8000'
|
|
111
|
+
|
|
112
|
+
minio:
|
|
113
|
+
image: ${MINIO_IMAGE:-minio/minio:latest}
|
|
114
|
+
restart: unless-stopped
|
|
115
|
+
command: server /data --console-address ":9001"
|
|
116
|
+
environment:
|
|
117
|
+
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
|
|
118
|
+
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
|
|
119
|
+
volumes:
|
|
120
|
+
- nextblock_media:/data
|
|
121
|
+
ports:
|
|
122
|
+
- '${MINIO_S3_PORT:-9000}:9000'
|
|
123
|
+
- '${MINIO_CONSOLE_PORT:-9001}:9001'
|
|
124
|
+
|
|
125
|
+
minio-init:
|
|
126
|
+
image: ${MINIO_MC_IMAGE:-minio/mc:latest}
|
|
127
|
+
restart: 'no'
|
|
128
|
+
depends_on:
|
|
129
|
+
minio:
|
|
130
|
+
condition: service_started
|
|
131
|
+
environment:
|
|
132
|
+
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
|
|
133
|
+
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
|
|
134
|
+
STORAGE_BUCKET: ${STORAGE_BUCKET:-nextblock}
|
|
135
|
+
entrypoint: >
|
|
136
|
+
/bin/sh -c "
|
|
137
|
+
until mc alias set local http://minio:9000 $$MINIO_ROOT_USER $$MINIO_ROOT_PASSWORD; do echo 'waiting for minio...'; sleep 2; done &&
|
|
138
|
+
mc mb --ignore-existing local/$$STORAGE_BUCKET &&
|
|
139
|
+
mc anonymous set download local/$$STORAGE_BUCKET &&
|
|
140
|
+
echo 'minio bucket ready'
|
|
141
|
+
"
|
|
142
|
+
|
|
143
|
+
migrate:
|
|
144
|
+
image: ${MIGRATE_IMAGE:-postgres:15-alpine}
|
|
145
|
+
restart: 'no'
|
|
146
|
+
depends_on:
|
|
147
|
+
db:
|
|
148
|
+
condition: service_healthy
|
|
149
|
+
auth:
|
|
150
|
+
condition: service_healthy
|
|
151
|
+
environment:
|
|
152
|
+
POSTGRES_HOST: db
|
|
153
|
+
POSTGRES_PORT: 5432
|
|
154
|
+
POSTGRES_DB: ${POSTGRES_DB:-postgres}
|
|
155
|
+
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
|
156
|
+
volumes:
|
|
157
|
+
- ./supabase/migrations:/migrations:ro
|
|
158
|
+
- ./docker/migrate/run-migrations.sh:/run-migrations.sh:ro
|
|
159
|
+
entrypoint: ['sh', '/run-migrations.sh']
|
|
160
|
+
|
|
161
|
+
nextblock-cms:
|
|
162
|
+
build:
|
|
163
|
+
context: .
|
|
164
|
+
dockerfile: Dockerfile
|
|
165
|
+
args:
|
|
166
|
+
NEXT_PUBLIC_SUPABASE_URL: ${NEXT_PUBLIC_SUPABASE_URL:-http://localhost:8000}
|
|
167
|
+
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${ANON_KEY}
|
|
168
|
+
NEXT_PUBLIC_URL: ${NEXT_PUBLIC_URL:-http://localhost:3000}
|
|
169
|
+
NEXT_PUBLIC_R2_PUBLIC_URL: ${NEXT_PUBLIC_R2_PUBLIC_URL}
|
|
170
|
+
NEXT_PUBLIC_R2_BASE_URL: ${NEXT_PUBLIC_R2_BASE_URL}
|
|
171
|
+
NEXT_PUBLIC_TURNSTILE_SITE_KEY: ${NEXT_PUBLIC_TURNSTILE_SITE_KEY:-}
|
|
172
|
+
NEXT_PUBLIC_IS_SANDBOX: ${NEXT_PUBLIC_IS_SANDBOX:-true}
|
|
173
|
+
restart: unless-stopped
|
|
174
|
+
depends_on:
|
|
175
|
+
migrate:
|
|
176
|
+
condition: service_completed_successfully
|
|
177
|
+
kong:
|
|
178
|
+
condition: service_started
|
|
179
|
+
minio-init:
|
|
180
|
+
condition: service_completed_successfully
|
|
181
|
+
environment:
|
|
182
|
+
NODE_ENV: production
|
|
183
|
+
PORT: 3000
|
|
184
|
+
HOSTNAME: 0.0.0.0
|
|
185
|
+
# Browser AND server both use localhost:8000; the in-container loopback proxy (see Dockerfile)
|
|
186
|
+
# forwards it to Kong, keeping the host-derived Supabase auth cookie key consistent for SSR.
|
|
187
|
+
NEXT_PUBLIC_SUPABASE_URL: ${NEXT_PUBLIC_SUPABASE_URL:-http://localhost:8000}
|
|
188
|
+
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${ANON_KEY}
|
|
189
|
+
SUPABASE_SERVICE_ROLE_KEY: ${SERVICE_ROLE_KEY}
|
|
190
|
+
NEXT_PUBLIC_URL: ${NEXT_PUBLIC_URL:-http://localhost:3000}
|
|
191
|
+
NEXT_PUBLIC_IS_SANDBOX: ${NEXT_PUBLIC_IS_SANDBOX:-true}
|
|
192
|
+
CRON_SECRET: ${CRON_SECRET}
|
|
193
|
+
DRAFT_MODE_SECRET: ${DRAFT_MODE_SECRET}
|
|
194
|
+
REVALIDATE_SECRET_TOKEN: ${REVALIDATE_SECRET_TOKEN}
|
|
195
|
+
CORTEX_AI_ENCRYPTION_KEY: ${CORTEX_AI_ENCRYPTION_KEY:-}
|
|
196
|
+
R2_ACCOUNT_ID: ${R2_ACCOUNT_ID:-minio}
|
|
197
|
+
R2_BUCKET_NAME: ${STORAGE_BUCKET:-nextblock}
|
|
198
|
+
R2_ACCESS_KEY_ID: ${MINIO_ROOT_USER}
|
|
199
|
+
R2_SECRET_ACCESS_KEY: ${MINIO_ROOT_PASSWORD}
|
|
200
|
+
R2_REGION: ${R2_REGION:-us-east-1}
|
|
201
|
+
R2_S3_ENDPOINT: ${R2_S3_ENDPOINT:-http://minio:9000}
|
|
202
|
+
R2_S3_PUBLIC_ENDPOINT: ${R2_S3_PUBLIC_ENDPOINT:-http://localhost:9000}
|
|
203
|
+
R2_FORCE_PATH_STYLE: 'true'
|
|
204
|
+
NEXT_PUBLIC_R2_PUBLIC_URL: ${NEXT_PUBLIC_R2_PUBLIC_URL}
|
|
205
|
+
NEXT_PUBLIC_R2_BASE_URL: ${NEXT_PUBLIC_R2_BASE_URL}
|
|
206
|
+
SMTP_HOST: ${SMTP_HOST:-}
|
|
207
|
+
SMTP_PORT: ${SMTP_PORT:-}
|
|
208
|
+
SMTP_USER: ${SMTP_USER:-}
|
|
209
|
+
SMTP_PASS: ${SMTP_PASS:-}
|
|
210
|
+
SMTP_FROM_EMAIL: ${SMTP_FROM_EMAIL:-}
|
|
211
|
+
SMTP_FROM_NAME: ${SMTP_FROM_NAME:-}
|
|
212
|
+
TURNSTILE_SECRET_KEY: ${TURNSTILE_SECRET_KEY:-}
|
|
213
|
+
NEXT_PUBLIC_TURNSTILE_SITE_KEY: ${NEXT_PUBLIC_TURNSTILE_SITE_KEY:-}
|
|
214
|
+
ports:
|
|
215
|
+
- '${APP_PORT:-3000}:3000'
|
|
216
|
+
|
|
217
|
+
volumes:
|
|
218
|
+
nextblock_db_store:
|
|
219
|
+
nextblock_media:
|