diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..9cabe495b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,23 @@ +.changeset +.git +.github +.turbo +**/.turbo +.vscode + +.env +.env.* +**/.env +**/.env.* +**/.next + +**/dist + +examples + +node_modules +**/node_modules + +deploy +!deploy/docker/**/entrypoint.sh +docker-compose.yaml diff --git a/.github/workflows/docker-build.yaml b/.github/workflows/docker-build.yaml new file mode 100644 index 000000000..9dbb0970a --- /dev/null +++ b/.github/workflows/docker-build.yaml @@ -0,0 +1,87 @@ +name: Docker Build and Push + +on: + push: + branches: + - dev + - main + - docker-build # remove me before PR merge + tags: + - "*.*.*" + +jobs: + build-dashboard: + name: Docker Build and Push Dashboard + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ secrets.DOCKER_REPO }}/stack-dashboard + tags: | + type=ref,event=branch + type=sha,prefix= + type=match,pattern=\d.\d.\d + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to DockerHub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USER }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + file: ./deploy/docker/dashboard/Dockerfile + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + + build-backend: + name: Docker Build and Push Backend + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ secrets.DOCKER_REPO }}/stack-backend + tags: | + type=ref,event=branch + type=sha,prefix= + type=match,pattern=\d.\d.\d + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to DockerHub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USER }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + file: ./deploy/docker/backend/Dockerfile + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/apps/backend/.env.docker b/apps/backend/.env.docker new file mode 100644 index 000000000..e39412954 --- /dev/null +++ b/apps/backend/.env.docker @@ -0,0 +1,34 @@ + +STACK_BASE_URL=http://host.docker.internal:8102 +STACK_SERVER_SECRET=23-wuNpik0gIW4mruTz25rbIvhuuvZFrLOLtL7J4tyo + +# Postgres connection strings +STACK_DATABASE_CONNECTION_STRING=postgres://stack:stack123@postgres:5432/stack +STACK_DIRECT_DATABASE_CONNECTION_STRING=postgres://stack:stack123@postgres:5432/stack + +# optionally skip migrations on container startup +STACK_SKIP_MIGRATIONS="false" + +# run Prisma seed script on container startup +STACK_RUN_SEED_SCRIPT="true" + +# Self-host seed script options +# Optionally create a default admin user and disable signups to the platform when the seed script is run. +# This can be useful for self hosted deployments where you want to restrict access to the Stack "internal" project. +# If not specified, no user will be created and you will have to create the first user manually by signing up. +# STACK_DEFAULT_ADMIN_EMAIL=admin@example.com +# STACK_DEFAULT_ADMIN_PASSWORD=admin123 +# STACK_DEFAULT_ADMIN_DISPLAY_NAME=Admin + +# Sign up +STACK_SIGN_UP_DISABLED= # set to "true" to disable sign up on the platform ("internal" project). If set to "true", the default admin user above will need to be created to log in + +STACK_EMAIL_HOST=inbucket +STACK_EMAIL_PORT=2500 +STACK_EMAIL_SECURE=false +STACK_EMAIL_USERNAME=does not matter, ignored by Inbucket +STACK_EMAIL_PASSWORD=does not matter, ignored by Inbucket +STACK_EMAIL_SENDER=noreply@example.com + +STACK_SVIX_SERVER_URL=http://host.docker.internal:8113 +STACK_SVIX_API_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE2NTUxNDA2MzksImV4cCI6MTk3MDUwMDYzOSwibmJmIjoxNjU1MTQwNjM5LCJpc3MiOiJzdml4LXNlcnZlciIsInN1YiI6Im9yZ18yM3JiOFlkR3FNVDBxSXpwZ0d3ZFhmSGlyTXUifQ.En8w77ZJWbd0qrMlHHupHUB-4cx17RfzFykseg95SUk diff --git a/apps/backend/next.config.mjs b/apps/backend/next.config.mjs index fc6d4d5b0..b2f09d8d9 100644 --- a/apps/backend/next.config.mjs +++ b/apps/backend/next.config.mjs @@ -49,6 +49,10 @@ const withConfiguredSentryConfig = (nextConfig) => /** @type {import('next').NextConfig} */ const nextConfig = { + // optionally set output to "standalone" for Docker builds + // https://nextjs.org/docs/pages/api-reference/next-config-js/output + output: process.env.NEXT_CONFIG_OUTPUT, + // we're open-source, so we can provide source maps productionBrowserSourceMaps: true, poweredByHeader: false, diff --git a/apps/backend/package.json b/apps/backend/package.json index cad34f8c5..5fb7beada 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -9,6 +9,7 @@ "with-env:prod": "dotenv -c --", "dev": "concurrently -n \"dev,codegen,prisma-studio\" -k \"next dev --port 8102\" \"pnpm run codegen:watch\" \"pnpm run prisma-studio\"", "build": "pnpm run codegen && next build", + "build:self-host-seed-script": "tsup --config prisma/tsup.config.ts", "analyze-bundle": "ANALYZE_BUNDLE=1 pnpm run build", "start": "next start --port 8102", "codegen-prisma": "pnpm run prisma generate", @@ -41,8 +42,8 @@ "@opentelemetry/sdk-trace-base": "^1.26.0", "@opentelemetry/sdk-trace-node": "^1.26.0", "@opentelemetry/semantic-conventions": "^1.27.0", - "@prisma/client": "^5.9.1", - "@prisma/instrumentation": "^5.19.1", + "@prisma/client": "^5.20.0", + "@prisma/instrumentation": "^5.20.0", "@sentry/nextjs": "^7.105.0", "@stackframe/stack-emails": "workspace:*", "@stackframe/stack-shared": "workspace:*", @@ -53,6 +54,7 @@ "dotenv-cli": "^7.3.0", "jose": "^5.2.2", "next": "^14.2.5", + "next-runtime-env": "^3.2.2", "nodemailer": "^6.9.10", "openid-client": "^5.6.4", "oslo": "^1.2.1", @@ -73,8 +75,9 @@ "@types/semver": "^7.5.8", "concurrently": "^8.2.2", "glob": "^10.4.1", - "prisma": "^5.9.1", + "prisma": "^5.20.0", "rimraf": "^5.0.5", + "tsup": "^8.3.0", "tsx": "^4.7.2" } } diff --git a/apps/backend/prisma/seed-self-host.ts b/apps/backend/prisma/seed-self-host.ts new file mode 100644 index 000000000..e5fd061a9 --- /dev/null +++ b/apps/backend/prisma/seed-self-host.ts @@ -0,0 +1,164 @@ +/* eslint-disable no-restricted-syntax */ +import { BooleanTrue, PrismaClient } from '@prisma/client'; +import { hashPassword } from '@stackframe/stack-shared/dist/utils/password'; + +const prisma = new PrismaClient(); + +async function seed() { + console.log('Seeding database...'); + + // Optional default admin user + const adminDisplayName = process.env.STACK_DEFAULT_ADMIN_DISPLAY_NAME || 'Admin'; + const adminEmail = process.env.STACK_DEFAULT_ADMIN_EMAIL; + const adminPassword = process.env.STACK_DEFAULT_ADMIN_PASSWORD; + + // Optionally disable sign up for "internal" project + const signUpEnabled = process.env.STACK_SIGN_UP_DISABLED !== 'true'; + + const existingProject = await prisma.project.findUnique({ + where: { + id: 'internal', + }, + }); + + if (existingProject) { + console.log('Internal project already exists, skipping seed script'); + return; + } + + await prisma.$transaction(async (tx) => { + const createdProject = await tx.project.create({ + data: { + id: 'internal', + displayName: 'Stack Dashboard', + description: 'Stack\'s admin dashboard', + isProductionMode: false, + apiKeySets: { + create: [{ + description: "Internal API key set", + // These keys must match the values used in the Stack dashboard env to be able to login via the UI. + publishableClientKey: process.env.NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY, + secretServerKey: process.env.STACK_SECRET_SERVER_KEY, + superSecretAdminKey: process.env.STACK_SUPER_SECRET_ADMIN_KEY, + expiresAt: new Date('2099-12-31T23:59:59Z'), + }], + }, + config: { + create: { + allowLocalhost: true, + signUpEnabled, // see STACK_SIGN_UP_DISABLED var above + emailServiceConfig: { + create: { + proxiedEmailServiceConfig: { + create: {} + } + } + }, + createTeamOnSignUp: false, + clientTeamCreationEnabled: false, + authMethodConfigs: { + create: [ + { + otpConfig: { + create: { + contactChannelType: 'EMAIL', + } + } + }, + { + passwordConfig: { + create: {}, + } + }, + ], + } + } + } + }, + }); + + console.log('Internal project created'); + + // Create optional default admin user if credentials are provided. + // This user will be able to login to the dashboard with both email/password and magic link. + if (adminEmail && adminPassword) { + const newUser = await tx.projectUser.create({ + data: { + projectId: 'internal', + displayName: adminDisplayName, + serverMetadata: { managedProjectIds: ['internal'] } + } + }); + + await tx.contactChannel.create({ + data: { + projectUserId: newUser.projectUserId, + projectId: 'internal', + type: 'EMAIL' as const, + value: adminEmail as string, + isVerified: true, + isPrimary: 'TRUE', + usedForAuth: BooleanTrue.TRUE, + } + }); + + const otpConfig = await tx.otpAuthMethodConfig.findFirstOrThrow({ + where: { + projectConfigId: createdProject.configId + }, + include: { + authMethodConfig: true, + } + }); + + await tx.authMethod.create({ + data: { + projectId: 'internal', + projectUserId: newUser.projectUserId, + projectConfigId: createdProject.configId, + authMethodConfigId: otpConfig.authMethodConfigId, + otpAuthMethod: { + create: { + projectUserId: newUser.projectUserId, + } + } + } + }); + + const passwordConfig = await tx.passwordAuthMethodConfig.findFirstOrThrow({ + where: { + projectConfigId: createdProject.configId + }, + include: { + authMethodConfig: true, + } + }); + + await tx.authMethod.create({ + data: { + projectId: 'internal', + projectConfigId: createdProject.configId, + projectUserId: newUser.projectUserId, + authMethodConfigId: passwordConfig.authMethodConfigId, + passwordAuthMethod: { + create: { + passwordHash: await hashPassword(adminPassword), + projectUserId: newUser.projectUserId, + } + } + } + }); + + console.log('Initial admin user created: ', adminEmail); + } + }); + + console.log('Seeding complete!'); +} + +seed().catch(async (e) => { + console.error(e); + await prisma.$disconnect(); + process.exit(1); +// eslint-disable-next-line @typescript-eslint/no-misused-promises +}).finally(async () => await prisma.$disconnect()); diff --git a/apps/backend/prisma/tsup.config.ts b/apps/backend/prisma/tsup.config.ts new file mode 100644 index 000000000..608299798 --- /dev/null +++ b/apps/backend/prisma/tsup.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'tsup'; + +// tsup config to build the self-hosting seed script so it can be +// run in the Docker container with no extra dependencies. +export default defineConfig({ + entry: ['prisma/seed-self-host.ts'], + format: ['cjs'], + outDir: 'dist', + target: 'node22', + platform: 'node', + noExternal: ['@stackframe/stack-shared', '@prisma/client'], + clean: true +}); diff --git a/apps/backend/src/analytics.tsx b/apps/backend/src/analytics.tsx index 6b29f1e3c..861cdc93d 100644 --- a/apps/backend/src/analytics.tsx +++ b/apps/backend/src/analytics.tsx @@ -1,8 +1,8 @@ -import { getEnvVariable } from '@stackframe/stack-shared/dist/utils/env'; +import { env } from "next-runtime-env"; import { PostHog } from 'posthog-node'; export default async function withPostHog(callback: (posthog: PostHog) => Promise) { - const postHogKey = getEnvVariable("NEXT_PUBLIC_POSTHOG_KEY", "phc_vIUFi0HzHo7oV26OsaZbUASqxvs8qOmap1UBYAutU4k"); + const postHogKey = env("NEXT_PUBLIC_POSTHOG_KEY") || "phc_vIUFi0HzHo7oV26OsaZbUASqxvs8qOmap1UBYAutU4k"; const posthogClient = new PostHog(postHogKey, { host: "https://eu.i.posthog.com", flushAt: 1, diff --git a/apps/dashboard/.env.docker b/apps/dashboard/.env.docker new file mode 100644 index 000000000..bc3c77bb5 --- /dev/null +++ b/apps/dashboard/.env.docker @@ -0,0 +1,10 @@ +NEXT_PUBLIC_STACK_URL=http://host.docker.internal:8102 + +NEXT_PUBLIC_STACK_PROJECT_ID=internal +NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY=this-publishable-client-key-is-for-local-development-only +STACK_SECRET_SERVER_KEY=this-secret-server-key-is-for-local-development-only +STACK_SUPER_SECRET_ADMIN_KEY=this-super-secret-admin-key-is-for-local-development-only + +NEXT_PUBLIC_INSECURE_COOKIE="true" # required for local docker testing on localhost + +NEXT_PUBLIC_STACK_SVIX_SERVER_URL=http://host.docker.internal:8113 diff --git a/apps/dashboard/next.config.mjs b/apps/dashboard/next.config.mjs index 50a163d33..dde7df3e9 100644 --- a/apps/dashboard/next.config.mjs +++ b/apps/dashboard/next.config.mjs @@ -63,6 +63,10 @@ const withConfiguredSentryConfig = (nextConfig) => /** @type {import('next').NextConfig} */ const nextConfig = { + // optionally set output to "standalone" for Docker builds + // https://nextjs.org/docs/pages/api-reference/next-config-js/output + output: process.env.NEXT_CONFIG_OUTPUT, + pageExtensions: ["js", "jsx", "mdx", "ts", "tsx"], // we're open-source, so we can provide source maps diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index 38877f4c8..a90ecd3a6 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -40,6 +40,7 @@ "jose": "^5.2.2", "lucide-react": "^0.378.0", "next": "^14.2.5", + "next-runtime-env": "^3.2.2", "next-themes": "^0.2.1", "nodemailer": "^6.9.10", "openid-client": "^5.6.4", diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/providers.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/providers.tsx index c8e059aa4..207e23340 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/providers.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/providers.tsx @@ -6,6 +6,7 @@ import { AdminProject } from "@stackframe/stack"; import { sharedProviders } from "@stackframe/stack-shared/dist/utils/oauth"; import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises"; import { ActionDialog, Badge, InlineCode, Label, SimpleTooltip, Typography } from "@stackframe/stack-ui"; +import { env } from "next-runtime-env"; import { useState } from "react"; import * as yup from "yup"; @@ -107,7 +108,7 @@ export function ProviderSettingDialog(props: Props & { open: boolean, onClose: ( - {`${process.env.NEXT_PUBLIC_STACK_URL}/api/v1/auth/oauth/callback/${props.id}`} + {`${env("NEXT_PUBLIC_STACK_URL")}/api/v1/auth/oauth/callback/${props.id}`} } diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/webhooks/[endpointId]/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/webhooks/[endpointId]/page-client.tsx index 6024629d3..66e312e7e 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/webhooks/[endpointId]/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/webhooks/[endpointId]/page-client.tsx @@ -2,6 +2,7 @@ import { SettingCard, SettingSwitch } from "@/components/settings"; import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises"; +import { env } from "next-runtime-env"; import { Alert, Badge, Button, Checkbox, CopyButton, Label, Table, TableBody, TableCell, TableHead, TableHeader, TableRow, Typography } from "@stackframe/stack-ui"; import { ChevronLeft, ChevronRight } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; @@ -187,7 +188,7 @@ export default function PageClient(props: { endpointId: string }) { diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/webhooks/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/webhooks/page-client.tsx index 9cf6b7334..c9d973031 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/webhooks/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/webhooks/page-client.tsx @@ -3,6 +3,7 @@ import { SmartFormDialog } from "@/components/form-dialog"; import { SettingCard } from "@/components/settings"; import { urlSchema } from "@stackframe/stack-shared/dist/schema-fields"; +import { env } from "next-runtime-env"; import { ActionCell, ActionDialog, Alert, Button, Table, TableBody, TableCell, TableHead, TableHeader, TableRow, Typography } from "@stackframe/stack-ui"; import { useRouter } from "next/navigation"; import { useMemo, useState } from "react"; @@ -192,7 +193,7 @@ export default function PageClient() { setUpdateCounter(x => x + 1)} /> diff --git a/apps/dashboard/src/app/layout.tsx b/apps/dashboard/src/app/layout.tsx index 62beeec09..c431ca818 100644 --- a/apps/dashboard/src/app/layout.tsx +++ b/apps/dashboard/src/app/layout.tsx @@ -6,6 +6,7 @@ import { cn } from '@/lib/utils'; import { stackServerApp } from '@/stack'; import { StackProvider, StackTheme } from '@stackframe/stack'; import { getEnvVariable, getNodeEnvironment } from '@stackframe/stack-shared/dist/utils/env'; +import { PublicEnvScript, env } from "next-runtime-env"; import { Toaster } from '@stackframe/stack-ui'; import { Analytics } from "@vercel/analytics/react"; import { GeistMono } from "geist/font/mono"; @@ -22,7 +23,7 @@ import { SpeedInsights } from "@vercel/speed-insights/next"; import { VersionAlerter } from '../components/version-alerter'; export const metadata: Metadata = { - metadataBase: new URL(process.env.NEXT_PUBLIC_STACK_URL || ''), + metadataBase: new URL(env('NEXT_PUBLIC_STACK_URL') || ''), title: { default: 'Stack Auth Dashboard', template: '%s | Stack Auth', @@ -31,12 +32,12 @@ export const metadata: Metadata = { openGraph: { title: 'Stack Auth Dashboard', description: 'Stack Auth is the open-source Auth0 alternative, and the fastest way to add authentication to your web app.', - images: [`${process.env.NEXT_PUBLIC_STACK_URL}/open-graph-image.png`] + images: [`${env("NEXT_PUBLIC_STACK_URL")}/open-graph-image.png`] }, twitter: { title: 'Stack Auth Dashboard', description: 'Stack Auth is the open-source Auth0 alternative, and the fastest way to add authentication to your web app.', - images: [`${process.env.NEXT_PUBLIC_STACK_URL}/open-graph-image.png`] + images: [`${env("NEXT_PUBLIC_STACK_URL")}/open-graph-image.png`] }, }; @@ -60,7 +61,7 @@ export default function RootLayout({ }: { children: React.ReactNode, }) { - const headTags: TagConfigJson[] = JSON.parse(getEnvVariable('NEXT_PUBLIC_STACK_HEAD_TAGS')); + const headTags: TagConfigJson[] = JSON.parse(env("NEXT_PUBLIC_STACK_HEAD_TAGS") || "[]"); const translationLocale = getEnvVariable('STACK_DEVELOPMENT_TRANSLATION_LOCALE', "") || undefined; if (translationLocale !== undefined && getNodeEnvironment() !== 'development') { throw new Error(`STACK_DEVELOPMENT_TRANSLATION_LOCALE can only be used in development mode (found: ${JSON.stringify(translationLocale)})`); @@ -69,6 +70,7 @@ export default function RootLayout({ return ( + diff --git a/apps/dashboard/src/app/providers.tsx b/apps/dashboard/src/app/providers.tsx index 4f18ec4d5..099b502db 100644 --- a/apps/dashboard/src/app/providers.tsx +++ b/apps/dashboard/src/app/providers.tsx @@ -1,11 +1,13 @@ 'use client'; import { useStackApp, useUser } from '@stackframe/stack'; +import { env } from "next-runtime-env"; import posthog from 'posthog-js'; import { PostHogProvider } from 'posthog-js/react'; import { Suspense, useEffect, useState } from 'react'; if (typeof window !== 'undefined') { - const postHogKey = process.env.NEXT_PUBLIC_POSTHOG_KEY ?? "phc_vIUFi0HzHo7oV26OsaZbUASqxvs8qOmap1UBYAutU4k"; + const postHogKey = env("NEXT_PUBLIC_POSTHOG_KEY") ?? "phc_vIUFi0HzHo7oV26OsaZbUASqxvs8qOmap1UBYAutU4k"; + if (postHogKey.length > 5) { posthog.init(postHogKey, { api_host: "/consume", diff --git a/deploy/docker/backend/Dockerfile b/deploy/docker/backend/Dockerfile new file mode 100644 index 000000000..cc62835a6 --- /dev/null +++ b/deploy/docker/backend/Dockerfile @@ -0,0 +1,93 @@ +ARG NODE_VERSION=22.9.0 + +# Base +FROM node:${NODE_VERSION} AS base + +WORKDIR /app + +RUN apt-get update && \ + apt-get upgrade -y && \ + rm -rf /var/lib/apt/lists + +ENV PNPM_HOME=/pnpm +ENV PATH=$PNPM_HOME:$PATH + +RUN corepack enable +RUN corepack prepare pnpm --activate +RUN pnpm add -g turbo + + +# Prune stage +FROM base AS pruner + +COPY . . + +# https://turbo.build/repo/docs/guides/tools/docker +RUN turbo prune --scope=@stackframe/stack-backend --docker + + +# Build stage +FROM base AS builder + +# copy over package.json files and install dependencies +COPY --from=pruner /app/out/json/ . +COPY --from=pruner /app/out/pnpm-lock.yaml . +COPY .gitignore . +COPY pnpm-workspace.yaml . +COPY turbo.json . +RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile + +# copy over the rest of the code for the build +COPY --from=pruner /app/out/full/ . + +# docs are currently required for the NextJS backend build, but won't exist in the final image +COPY docs ./docs + +# Required for NextJS build. Should be overridden at runtime. +# check=skip=SecretsUsedInArgOrEnv +ENV STACK_SERVER_SECRET=secret + +# https://nextjs.org/docs/pages/api-reference/next-config-js/output +ENV NEXT_CONFIG_OUTPUT=standalone + +# Build the backend NextJS app +RUN pnpm turbo run build --filter=@stackframe/stack-backend... + +# Build the self-hosting seed script +RUN cd apps/backend && pnpm build:self-host-seed-script + +# Final image +FROM node:${NODE_VERSION}-slim + +WORKDIR /app + +# Install packages needed for deployment +RUN apt-get update && \ + apt-get upgrade -y && \ + apt-get install -y openssl && \ + rm -rf /var/lib/apt/lists + +# Install Prisma CLI globally so we can run migrations on startup +RUN npm i -g prisma + +# Copy built application +COPY --from=builder --chown=node:node /app/apps/backend/.next/standalone ./ +COPY --from=builder --chown=node:node /app/apps/backend/.next/static ./apps/backend/.next/static +COPY --from=builder --chown=node:node /app/apps/backend/prisma ./apps/backend/prisma +COPY --from=builder --chown=node:node /app/apps/backend/dist/seed-self-host.js ./apps/backend + +WORKDIR /app/apps/backend + +ENV NODE_ENV=production + +# can be overridden to any preferred port at runtime as needed +# https://nextjs.org/docs/app/api-reference/cli/next#changing-the-default-port +ENV PORT=8102 + +COPY ./deploy/docker/backend/entrypoint.sh . + +USER node + +ENTRYPOINT ["./entrypoint.sh"] + +CMD ["node", "server.js"] diff --git a/deploy/docker/backend/entrypoint.sh b/deploy/docker/backend/entrypoint.sh new file mode 100755 index 000000000..90bbb8dbe --- /dev/null +++ b/deploy/docker/backend/entrypoint.sh @@ -0,0 +1,20 @@ +#!/bin/bash + +set -e + +if [ "$STACK_SKIP_MIGRATIONS" != "true" ]; then + echo "Running migrations..." + prisma migrate deploy +else + echo "Skipping migrations." +fi + +if [ "$STACK_RUN_SEED_SCRIPT" = "true" ]; then + echo "Running seed script..." + node seed-self-host.js +else + echo "Skipping seed script." +fi + +# Run the main container command +exec "$@" diff --git a/deploy/docker/dashboard/Dockerfile b/deploy/docker/dashboard/Dockerfile new file mode 100644 index 000000000..d213796c0 --- /dev/null +++ b/deploy/docker/dashboard/Dockerfile @@ -0,0 +1,83 @@ +ARG NODE_VERSION=22.9.0 + +# Base +FROM node:${NODE_VERSION} AS base + +WORKDIR /app + +RUN apt-get update && \ + apt-get upgrade -y && \ + rm -rf /var/lib/apt/lists + +ENV PNPM_HOME=/pnpm +ENV PATH=$PNPM_HOME:$PATH + +RUN corepack enable +RUN corepack prepare pnpm --activate +RUN pnpm add -g turbo + + +# Prune stage +FROM base AS pruner + +COPY . . + +# https://turbo.build/repo/docs/guides/tools/docker +RUN turbo prune --scope=@stackframe/stack-dashboard --docker + + +# Build stage +FROM base AS builder + +# copy over package.json files and install dependencies +COPY --from=pruner /app/out/json/ . +COPY --from=pruner /app/out/pnpm-lock.yaml . +COPY .gitignore . +COPY pnpm-workspace.yaml . +COPY turbo.json . +RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile + +# copy over the rest of the code for the build +COPY --from=pruner /app/out/full/ . + +# The following values are required for the NextJS build, but they should all be overridden at runtime. +ENV NEXT_PUBLIC_STACK_URL=http://localhost:8102 +ENV NEXT_PUBLIC_STACK_SVIX_SERVER_URL=http://localhost:8113 +ENV NEXT_PUBLIC_STACK_PROJECT_ID=internal +ENV NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY=this-publishable-client-key-is-for-local-development-only +ENV STACK_SECRET_SERVER_KEY=this-secret-server-key-is-for-local-development-only + +# https://nextjs.org/docs/pages/api-reference/next-config-js/output +ENV NEXT_CONFIG_OUTPUT=standalone + +# Build the dashboard NextJS app +RUN pnpm turbo run build --filter=@stackframe/stack-dashboard... + + +# Final image +FROM node:${NODE_VERSION}-slim + +WORKDIR /app + +# Install packages needed for deployment +RUN apt-get update && \ + apt-get upgrade -y && \ + apt-get install -y openssl && \ + rm -rf /var/lib/apt/lists + +# Copy built application +COPY --from=builder --chown=node:node /app/apps/dashboard/.next/standalone ./ +COPY --from=builder --chown=node:node /app/apps/dashboard/.next/static ./apps/dashboard/.next/static +COPY --from=builder --chown=node:node /app/apps/dashboard/public ./apps/dashboard/public + +WORKDIR /app/apps/dashboard + +ENV NODE_ENV=production + +# can be overridden to any preferred port at runtime as needed +# https://nextjs.org/docs/app/api-reference/cli/next#changing-the-default-port +ENV PORT=8101 + +USER node + +CMD ["node", "server.js"] diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 000000000..180814a2a --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,120 @@ +services: + # ================= stack ================= + + dashboard: + container_name: stack-dashboard + build: + context: . + dockerfile: ./deploy/docker/dashboard/Dockerfile + env_file: + - apps/dashboard/.env.docker + ports: + - 8101:8101 + depends_on: + postgres: + condition: service_healthy + extra_hosts: + - "host.docker.internal:host-gateway" + + backend: + container_name: stack-backend + build: + context: . + dockerfile: ./deploy/docker/backend/Dockerfile + env_file: + - apps/dashboard/.env.docker # used for ensuring matching shared API keys between dashboard and backend seed script + - apps/backend/.env.docker + ports: + - 8102:8102 + depends_on: + postgres: + condition: service_healthy + inbucket: + condition: service_started + svix-server: + condition: service_started + extra_hosts: + - "host.docker.internal:host-gateway" + + # ================= PostgreSQL ================= + + postgres: + image: postgres:latest + environment: + POSTGRES_USER: stack + POSTGRES_PASSWORD: stack123 + POSTGRES_DB: stack + volumes: + - postgres-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U stack"] + interval: 1s + timeout: 2s + retries: 20 + + # ================= Inbucket ================= + + inbucket: + image: inbucket/inbucket:latest + ports: + - 8105:9000 + volumes: + - inbucket-data:/data + + # ================= OpenTelemetry & Jaeger ================= + + jaeger: + image: jaegertracing/all-in-one:latest + environment: + - COLLECTOR_OTLP_ENABLED=true + restart: always + + # ================= svix ================= + + svix-db: + image: postgres:latest + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: password + POSTGRES_DB: svix + volumes: + - svix-postgres-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 1s + timeout: 2s + retries: 10 + + svix-redis: + image: docker.io/redis:7-alpine + command: --save 60 500 --appendonly yes --appendfsync everysec + volumes: + - svix-redis-data:/data + + svix-server: + image: svix/svix-server + environment: + WAIT_FOR: 'true' + SVIX_DB_DSN: postgres://postgres:password@svix-db:5432/svix + SVIX_REDIS_DSN: redis://svix-redis:6379 + SVIX_QUEUE_TYPE: redis + SVIX_CACHE_TYPE: redis + SVIX_JWT_SECRET: secret + SVIX_LOG_LEVEL: trace + ports: + - 8113:8071 + depends_on: + - svix-redis + - svix-db + +# ================= volumes ================= + +volumes: + postgres-data: + name: stack-postgres + inbucket-data: + name: stack-inbucket + svix-redis-data: + name: svix-redis + svix-postgres-data: + name: svix-postgres diff --git a/package.json b/package.json index a0e72726d..aa616448e 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "codegen": "only-allow pnpm && turbo run codegen", "deps-compose": "docker compose -f dependencies.compose.yaml", "stop-deps": "pnpm run deps-compose kill && pnpm run deps-compose down -v", - "init-db": "pnpm run prisma db push && pnpm run prisma db seed", + "init-db": "pnpm run prisma db push && pnpm run prisma db seed", "start-deps:no-delay": "pnpm run deps-compose up --detach && sleep 5 && pnpm run init-db && echo \"\\nDependencies started in the background as Docker containers. 'pnpm run stop-deps' to stop them\"n", "start-deps": "POSTGRES_DELAY_MS=${POSTGRES_DELAY_MS:-20} pnpm run start-deps:no-delay", "restart-deps": "pnpm run stop-deps && pnpm run start-deps", @@ -24,6 +24,10 @@ "prisma": "only-allow pnpm && pnpm run --filter=@stackframe/stack-backend prisma", "fern": "only-allow pnpm && pnpm run --filter=@stackframe/docs fern", "dev": "only-allow pnpm && turbo run dev --concurrency 99999", + "docker:build": "docker compose build", + "docker:up": "docker compose up -d && docker compose logs -f dashboard backend", + "docker:down": "docker compose down", + "docker:reset": "docker compose kill && docker compose rm -f && docker volume rm stack-postgres stack-inbucket svix-redis svix-postgres", "start": "only-allow pnpm && turbo run start --concurrency 99999", "start:backend": "only-allow pnpm && turbo run start --concurrency 99999 --filter=@stackframe/stack-backend", "start:dashboard": "only-allow pnpm && turbo run start --concurrency 99999 --filter=@stackframe/stack-dashboard", @@ -70,5 +74,5 @@ "node": ">=20.0.0" }, "dependencies": {}, - "packageManager": "pnpm@9.1.2" + "packageManager": "pnpm@9.12.1" } diff --git a/packages/stack/src/lib/env/env.ts b/packages/stack/src/lib/env/env.ts new file mode 100644 index 000000000..db7332b50 --- /dev/null +++ b/packages/stack/src/lib/env/env.ts @@ -0,0 +1,32 @@ +import { unstable_noStore as noStore } from 'next/cache'; +import { PUBLIC_ENV_KEY } from './public-env-script'; + +function isBrowser(): boolean { + return Boolean(typeof window !== 'undefined' && window[PUBLIC_ENV_KEY as keyof typeof window]); +} + +/** + * Reads any NEXT_PUBLIC_ environment variable from the browser or any environment + * variable from the server (process.env). Throws an error if trying to read a non-NEXT_PUBLIC_ + * environment variable in the browser. + * + * Usage: + * ```ts + * const API_URL = env('NEXT_PUBLIC_API_URL'); + * ``` + */ +export function env(key: string): string | undefined { + if (isBrowser()) { + if (!key.startsWith('NEXT_PUBLIC_')) { + throw new Error( + `Environment variable '${key}' is not public and cannot be accessed in the browser.`, + ); + } + + return window[PUBLIC_ENV_KEY as keyof typeof window][key]; + } + + noStore(); + + return process.env[key]; +} diff --git a/packages/stack/src/lib/env/index.tsx b/packages/stack/src/lib/env/index.tsx new file mode 100644 index 000000000..1fba01fda --- /dev/null +++ b/packages/stack/src/lib/env/index.tsx @@ -0,0 +1,2 @@ +export * from './env'; +export * from './public-env-script'; diff --git a/packages/stack/src/lib/env/public-env-script.tsx b/packages/stack/src/lib/env/public-env-script.tsx new file mode 100644 index 000000000..04efe493d --- /dev/null +++ b/packages/stack/src/lib/env/public-env-script.tsx @@ -0,0 +1,52 @@ +import { unstable_noStore as noStore } from 'next/cache'; +import Script from 'next/script'; +import { type FC } from 'react'; + +// Set the key that the PublicEnvScript component uses to set the environment variables +// on the window object in the browser. eg. window.__STACK_ENV__ +export const PUBLIC_ENV_KEY = '__STACK_ENV__'; + +// Get a list of environment variables that start with `NEXT_PUBLIC_`. +function getPublicEnv() { + const publicEnv = Object.keys(process.env) + .filter((key) => /^NEXT_PUBLIC_/i.test(key)) + .reduce( + (env, key) => ({ + ...env, + [key]: process.env[key], + }), + {} as NodeJS.ProcessEnv, + ); + + return publicEnv; +} + +/** + * Makes public environment variables available on the window object in the browser. + * + * This component disables Next.js' caching mechanism to ensure that the + * environment variables are always up-to-date when this component renders. + * + * Usage: + * ```tsx + * + * + * + * ``` + */ +export const PublicEnvScript: FC = () => { + // Opt into dynamic rendering + // https://nextjs.org/docs/app/api-reference/functions/unstable_noStore + noStore(); + + // Env values will now be evaluated at runtime + const publicEnv = getPublicEnv(); + + return