diff --git a/app/src/components/TemplateDialog.tsx b/app/src/components/TemplateDialog.tsx index b1e9b2f0..de10ce38 100644 --- a/app/src/components/TemplateDialog.tsx +++ b/app/src/components/TemplateDialog.tsx @@ -14,6 +14,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs"; import { Label } from "./ui/label"; import { Clipboard } from "lucide-react"; import { Input } from "./ui/input"; +import Markdown from "./ui/markdown"; interface Template { id: string; @@ -32,6 +33,7 @@ interface Template { interface TemplateFiles { dockerCompose: string | null; config: string | null; + instructions: string | null; } interface TemplateDialogProps { @@ -169,8 +171,23 @@ const TemplateDialog: React.FC = ({ )} - + + {templateFiles?.instructions && ( + + Instructions + + )} = ({ Configuration + + {templateFiles?.instructions && ( + +
+ +
+ +
+
+
+ )} {templateFiles?.dockerCompose && (
diff --git a/app/src/components/TemplateGrid.tsx b/app/src/components/TemplateGrid.tsx index 8f51dd69..42e1a655 100644 --- a/app/src/components/TemplateGrid.tsx +++ b/app/src/components/TemplateGrid.tsx @@ -28,6 +28,7 @@ interface Template { interface TemplateFiles { dockerCompose: string | null; config: string | null; + instructions: string | null; } interface TemplateGridProps { @@ -80,20 +81,34 @@ const TemplateGrid: React.FC = ({ view }) => { const fetchTemplateFiles = async (templateId: string) => { setModalLoading(true); try { - const [dockerComposeRes, configRes] = await Promise.all([ - fetch(`/blueprints/${templateId}/docker-compose.yml`), - fetch(`/blueprints/${templateId}/template.toml`), - ]); + const [dockerComposeRes, configRes, instructionsRes] = await Promise.all( + [ + fetch(`/blueprints/${templateId}/docker-compose.yml`), + fetch(`/blueprints/${templateId}/template.toml`), + fetch(`/blueprints/${templateId}/instructions.md`), + ] + ); const dockerCompose = dockerComposeRes.ok ? await dockerComposeRes.text() : null; const config = configRes.ok ? await configRes.text() : null; - setTemplateFiles({ dockerCompose, config }); + // Guard against SPA fallbacks that return index.html with a 200 status + // for templates that don't have an instructions.md file. + const instructionsIsMarkdown = + instructionsRes.ok && + !(instructionsRes.headers.get("content-type") || "").includes( + "text/html" + ); + const instructions = instructionsIsMarkdown + ? await instructionsRes.text() + : null; + + setTemplateFiles({ dockerCompose, config, instructions }); } catch (err) { console.error("Error fetching template files:", err); - setTemplateFiles({ dockerCompose: null, config: null }); + setTemplateFiles({ dockerCompose: null, config: null, instructions: null }); } finally { setModalLoading(false); } diff --git a/app/src/components/ui/markdown.tsx b/app/src/components/ui/markdown.tsx new file mode 100644 index 00000000..03c0ca87 --- /dev/null +++ b/app/src/components/ui/markdown.tsx @@ -0,0 +1,177 @@ +import React from "react"; + +// Minimal markdown renderer (no external dependencies) for the per-template +// instructions.md files. Supports headings, paragraphs, fenced code blocks, +// ordered/unordered lists, and inline code / bold / links. + +const INLINE_PATTERN = + /(`[^`]+`)|(\*\*[^*]+\*\*)|(\[[^\]]+\]\([^)\s]+\))/g; + +const renderInline = (text: string): React.ReactNode[] => { + const nodes: React.ReactNode[] = []; + let lastIndex = 0; + let key = 0; + let match: RegExpExecArray | null; + INLINE_PATTERN.lastIndex = 0; + + while ((match = INLINE_PATTERN.exec(text)) !== null) { + if (match.index > lastIndex) { + nodes.push(text.slice(lastIndex, match.index)); + } + const token = match[0]; + if (token.startsWith("`")) { + nodes.push( + + {token.slice(1, -1)} + + ); + } else if (token.startsWith("**")) { + nodes.push({token.slice(2, -2)}); + } else { + const link = /^\[([^\]]+)\]\(([^)\s]+)\)$/.exec(token); + if (link) { + nodes.push( + + {link[1]} + + ); + } else { + nodes.push(token); + } + } + lastIndex = match.index + token.length; + } + if (lastIndex < text.length) { + nodes.push(text.slice(lastIndex)); + } + return nodes; +}; + +const HEADING_CLASSES: Record = { + 1: "text-2xl font-bold mt-2", + 2: "text-xl font-semibold mt-6 border-b pb-1", + 3: "text-lg font-semibold mt-4", + 4: "text-base font-semibold mt-3", +}; + +interface MarkdownProps { + content: string; +} + +const Markdown: React.FC = ({ content }) => { + const lines = content.replace(/\r\n/g, "\n").split("\n"); + const blocks: React.ReactNode[] = []; + let key = 0; + let i = 0; + + while (i < lines.length) { + const line = lines[i]; + + // Blank line + if (line.trim() === "") { + i++; + continue; + } + + // Fenced code block + if (line.trim().startsWith("```")) { + const code: string[] = []; + i++; + while (i < lines.length && !lines[i].trim().startsWith("```")) { + code.push(lines[i]); + i++; + } + i++; // skip closing fence + blocks.push( +
+          {code.join("\n")}
+        
+ ); + continue; + } + + // Heading + const heading = /^(#{1,4})\s+(.*)$/.exec(line); + if (heading) { + const level = heading[1].length; + const Tag = `h${Math.min(level + 1, 6)}` as keyof React.JSX.IntrinsicElements; + blocks.push( + + {renderInline(heading[2])} + + ); + i++; + continue; + } + + // Unordered list + if (/^[-*]\s+/.test(line)) { + const items: string[] = []; + while (i < lines.length && /^[-*]\s+/.test(lines[i])) { + items.push(lines[i].replace(/^[-*]\s+/, "")); + i++; + } + blocks.push( +
    + {items.map((item, idx) => ( +
  • {renderInline(item)}
  • + ))} +
+ ); + continue; + } + + // Ordered list + if (/^\d+\.\s+/.test(line)) { + const items: string[] = []; + while (i < lines.length && /^\d+\.\s+/.test(lines[i])) { + items.push(lines[i].replace(/^\d+\.\s+/, "")); + i++; + } + blocks.push( +
    + {items.map((item, idx) => ( +
  1. {renderInline(item)}
  2. + ))} +
+ ); + continue; + } + + // Paragraph: consume consecutive plain lines + const paragraph: string[] = [line]; + i++; + while ( + i < lines.length && + lines[i].trim() !== "" && + !/^(#{1,4})\s+/.test(lines[i]) && + !/^[-*]\s+/.test(lines[i]) && + !/^\d+\.\s+/.test(lines[i]) && + !lines[i].trim().startsWith("```") + ) { + paragraph.push(lines[i]); + i++; + } + blocks.push( +

+ {renderInline(paragraph.join(" "))} +

+ ); + } + + return
{blocks}
; +}; + +export default Markdown; diff --git a/blueprints/ackee/instructions.md b/blueprints/ackee/instructions.md deleted file mode 100644 index b107d360..00000000 --- a/blueprints/ackee/instructions.md +++ /dev/null @@ -1,4 +0,0 @@ - -## Instructions - -We don't have nothing to show here.... diff --git a/blueprints/supabase/instructions.md b/blueprints/supabase/instructions.md new file mode 100644 index 00000000..af95f213 --- /dev/null +++ b/blueprints/supabase/instructions.md @@ -0,0 +1,61 @@ +# Supabase Setup Instructions + +## Deploy + +1. In Dokploy, create the service from the **Supabase** template (requires Dokploy `>= 0.22.5`). +2. Dokploy automatically generates all secrets for you (`POSTGRES_PASSWORD`, `JWT_SECRET`, `ANON_KEY`, `SERVICE_ROLE_KEY`, `DASHBOARD_PASSWORD`, etc.). You can review them in the **Environment** tab of the service. +3. Deploy and wait for all containers to become healthy. The first deploy can take several minutes while the Postgres database initializes. + +## Log in to Supabase Studio + +The main domain of the template points to the `kong` API gateway (port `8000`), which protects Supabase Studio with basic authentication: + +- **Username**: the value of `DASHBOARD_USERNAME` (default: `supabase`) +- **Password**: the value of `DASHBOARD_PASSWORD` + +Both values are in the **Environment** tab of the service in Dokploy. + +## API URL and keys + +To connect an application (for example with `supabase-js`): + +- **API URL**: `https://` (requests are routed through Kong) +- **anon key**: the value of `ANON_KEY` in the Environment tab +- **service_role key**: the value of `SERVICE_ROLE_KEY` in the Environment tab (server-side only, never expose it to browsers) + +## Recommended configuration + +Review these variables in the **Environment** tab before using Supabase in production: + +- `SUPABASE_PUBLIC_URL` and `API_EXTERNAL_URL`: must point to your Supabase domain with the correct `http`/`https` scheme (the template sets them from your domain automatically). +- `SITE_URL` and `ADDITIONAL_REDIRECT_URLS`: must point to the application that uses Supabase for authentication. +- `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_ADMIN_EMAIL`, `SMTP_SENDER_NAME`: required for auth emails (sign-up confirmations, password resets). The template ships with placeholder values, so no real emails are sent until you configure a real SMTP provider. + +## Warning: changing POSTGRES_PASSWORD after the first deploy + +The Postgres data directory (mounted at `files/volumes/db/data`) is initialized **once**, on the first deploy, using the value of `POSTGRES_PASSWORD` at that moment. The same password is also assigned to the internal Supabase roles (`authenticator`, `pgbouncer`, `supabase_auth_admin`, `supabase_functions_admin`, `supabase_storage_admin`) by an init script that only runs on first boot. + +If you later change `POSTGRES_PASSWORD` in the Environment tab and redeploy, the password stored **inside the database does not change**. The other services will start using the new password while the database still expects the old one, and you will see errors such as `invalid_password` or `password authentication failed`. + +To actually change the password, use one of these options: + +### Option A: change it inside the database (keeps your data) + +1. Open a terminal into the `db` container (in Dokploy: your Supabase service, `db` container, **Terminal**) and run `psql -U postgres`. +2. Execute the following, using your new password: + +```sql +ALTER USER postgres WITH PASSWORD 'your-new-password'; +ALTER USER supabase_admin WITH PASSWORD 'your-new-password'; +ALTER USER authenticator WITH PASSWORD 'your-new-password'; +ALTER USER pgbouncer WITH PASSWORD 'your-new-password'; +ALTER USER supabase_auth_admin WITH PASSWORD 'your-new-password'; +ALTER USER supabase_functions_admin WITH PASSWORD 'your-new-password'; +ALTER USER supabase_storage_admin WITH PASSWORD 'your-new-password'; +``` + +3. Update `POSTGRES_PASSWORD` in the Environment tab to the same value and redeploy. + +### Option B: reinitialize the database (deletes ALL data) + +Only if the instance has no data you care about: stop the service, delete the `files/volumes/db/data` directory of the service, set the new `POSTGRES_PASSWORD`, and deploy again. The database will be initialized from scratch with the new password. diff --git a/blueprints/triggerdotdev/instructions.md b/blueprints/triggerdotdev/instructions.md new file mode 100644 index 00000000..2f0415d1 --- /dev/null +++ b/blueprints/triggerdotdev/instructions.md @@ -0,0 +1,54 @@ +# Trigger.dev Setup Instructions + +## Deploy + +1. In Dokploy, create the service from the **Trigger.dev** template. +2. Dokploy generates all required secrets (`MAGIC_LINK_SECRET`, `SESSION_SECRET`, `ENCRYPTION_KEY`, database credentials, etc.) automatically. +3. Deploy and wait until the containers are running. The main domain points to the `webapp` service (port `3000`). + +If you serve the app over HTTPS, set `TRIGGER_PROTOCOL=https` in the **Environment** tab (the template defaults to `http`) so that login links use the correct scheme, then redeploy. + +## First login (no email server configured) + +Trigger.dev logs you in with **magic links** sent by email. The template does not configure an email transport by default, so the email is never actually sent. Instead, **the magic link is printed to the logs of the `webapp` container**: + +1. Open `https://` and enter your email address to request a magic link. +2. In Dokploy, go to your Trigger.dev service, open the **Logs** tab, and select the `webapp` container/service. +3. Look for a recent log entry containing a URL like `.../magic?token=...` (search for `magic`). +4. Copy that URL into your browser to complete the login. + +## Configure email sending (optional) + +To have magic links delivered by email, add these variables in the **Environment** tab and redeploy. + +Using SMTP: + +``` +EMAIL_TRANSPORT=smtp +FROM_EMAIL=trigger@example.com +REPLY_TO_EMAIL=trigger@example.com +SMTP_HOST=smtp.example.com +SMTP_PORT=587 +SMTP_SECURE=false +SMTP_USER=your-smtp-user +SMTP_PASSWORD=your-smtp-password +``` + +Using [Resend](https://resend.com): + +``` +EMAIL_TRANSPORT=resend +FROM_EMAIL=trigger@example.com +REPLY_TO_EMAIL=trigger@example.com +RESEND_API_KEY=your-resend-api-key +``` + +## Restrict who can sign up (recommended) + +By default anyone who can reach the webapp can request a magic link. Restrict access with a regex of allowed email addresses: + +``` +WHITELISTED_EMAILS=you@example\.com|teammate@example\.com +``` + +You can also grant admin rights by email with `ADMIN_EMAILS` (same regex format).