diff --git a/.env.example b/.env.example
index 481a6c9..7c751d5 100644
--- a/.env.example
+++ b/.env.example
@@ -1,68 +1,153 @@
-# beenvoice-web environment
+# =============================================================================
+# beenvoice-web — environment template
+# =============================================================================
#
-# Local dev: cp .env.example .env.local
-# Docker: cp .env.example .env
+# Quick start (local dev):
+# cp .env.example .env.local
+# docker compose -f docker-compose.dev.yml up -d
+# bun run db:push # or: bun run db:migrate
+# bun run dev
#
-# Docker deploy checklist:
-# 1. Set AUTH_SECRET, BETTER_AUTH_URL, NEXT_PUBLIC_APP_URL to your public URL
-# 2. docker compose build --no-cache app # NEXT_PUBLIC_* is baked in at build
-# 3. docker compose up -d --build # migrations run on app container start
+# Quick start (Docker app + Postgres):
+# cp .env.example .env
+# # edit AUTH_SECRET + public URLs below
+# docker compose up -d --build
#
-# Updating: git pull && docker compose up -d --build
-# (git pull alone does not rebuild; up -d without --build keeps the old image)
+# -----------------------------------------------------------------------------
+# Build-time vs runtime (Docker)
+# -----------------------------------------------------------------------------
#
-# Migrations are idempotent — only pending SQL files are applied on each start.
+# Baked into the image at `docker compose build` (rebuild after changes):
+# NEXT_PUBLIC_APP_URL
+# NEXT_PUBLIC_* branding / theme defaults
+# NEXT_PUBLIC_AUTHENTIK_ENABLED
+# NEXT_PUBLIC_UMAMI_*
+#
+# Read from .env when the container starts (restart app after changes):
+# AUTH_SECRET, BETTER_AUTH_URL, DATABASE_URL (compose overrides host),
+# RESEND_*, DISABLE_SIGNUPS, AUTHENTIK_* secrets, CRON_SECRET
+#
+# `NEXT_PUBLIC_APP_URL` should still match your public browser URL for SSR,
+# emails, and MCP links. In the browser, sign-in uses the current page origin
+# automatically so dev works when Next picks another port (e.g. 3002).
+#
+# Updating production: git pull && docker compose up -d --build
+# Migrations run on every app start (idempotent — only pending SQL is applied).
-# Runtime
-NODE_ENV=production
-WEB_PORT=3000
+# =============================================================================
+# Core — required
+# =============================================================================
-# Auth
-# Generate with: openssl rand -base64 32
+# PostgreSQL connection string.
+# Local dev (docker-compose.dev.yml): host is localhost
+DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres
+
+# Session signing secret. Required in production.
+# Generate: openssl rand -base64 32
AUTH_SECRET=change-me-generate-a-real-secret
-# Must match the URL users open in the browser (include https:// and port if non-standard).
+
+# Public URL users open in the browser (scheme + host + port if non-standard).
+# Must match how you access the app for cookies, OAuth callbacks, and email links.
BETTER_AUTH_URL=http://localhost:3000
-# Public app URL — baked into the client bundle at Docker build time.
-# Set this to the same value as BETTER_AUTH_URL before `docker compose build`.
+# Same as BETTER_AUTH_URL in most setups. Embedded in the client bundle at build.
NEXT_PUBLIC_APP_URL=http://localhost:3000
-# Postgres used by docker-compose.yml
+# =============================================================================
+# Local development
+# =============================================================================
+
+NODE_ENV=development
+
+# Set true when connecting to local Postgres without SSL (default for compose).
+DB_DISABLE_SSL=true
+
+# Dev-only: host port for `docker compose -f docker-compose.dev.yml` Postgres.
+POSTGRES_PORT=5432
+
+# Optional: if Next dev picks another port, you do not need to change URLs for
+# sign-in — the auth client uses window.location.origin in the browser.
+
+# =============================================================================
+# Docker Compose (app + database)
+# =============================================================================
+
+# Host port mapped to container :3000 (WEB_PORT, then PORT, then 3000).
+WEB_PORT=3000
+
+# Postgres credentials for docker-compose.yml `db` service.
+# DATABASE_URL inside the app container is set by compose (host `db`, not localhost).
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_DB=postgres
-DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres
-DB_DISABLE_SSL=true
-# White-label defaults used at image build time.
-# Admin-managed platform branding in the app can override these after setup.
-NEXT_PUBLIC_BRAND_NAME="beenvoice"
-NEXT_PUBLIC_BRAND_TAGLINE="Simple and efficient invoicing for freelancers and small businesses"
-NEXT_PUBLIC_BRAND_LOGO_TEXT="beenvoice"
-NEXT_PUBLIC_BRAND_ICON="$"
-NEXT_PUBLIC_DEFAULT_INTERFACE_THEME="beenvoice"
-NEXT_PUBLIC_DEFAULT_FONT="brand"
-NEXT_PUBLIC_DEFAULT_BODY_FONT="brand"
-NEXT_PUBLIC_DEFAULT_HEADING_FONT="brand"
-NEXT_PUBLIC_DEFAULT_RADIUS="xl"
-NEXT_PUBLIC_DEFAULT_SIDEBAR_STYLE="floating"
+# =============================================================================
+# White-label defaults (optional)
+# =============================================================================
+# Baked in at Docker build. After first deploy, admins can override many of
+# these from Settings → Appearance in the dashboard.
+
+NEXT_PUBLIC_BRAND_NAME=beenvoice
+NEXT_PUBLIC_BRAND_TAGLINE=Simple and efficient invoicing for freelancers and small businesses
+NEXT_PUBLIC_BRAND_LOGO_TEXT=beenvoice
+NEXT_PUBLIC_BRAND_ICON=$
+
+# Interface theme: beenvoice | frutiger | frutiger-aero | shadcn | minimal | editorial
+NEXT_PUBLIC_DEFAULT_INTERFACE_THEME=beenvoice
+
+# Font prefs: brand | frutiger | platform | inter | serif
+NEXT_PUBLIC_DEFAULT_FONT=brand
+NEXT_PUBLIC_DEFAULT_BODY_FONT=brand
+NEXT_PUBLIC_DEFAULT_HEADING_FONT=brand
+
+# Corner radius: none | sm | md | lg | xl
+NEXT_PUBLIC_DEFAULT_RADIUS=xl
+
+# Sidebar chrome: floating | docked
+NEXT_PUBLIC_DEFAULT_SIDEBAR_STYLE=floating
+
+# =============================================================================
+# Email — Resend (optional)
+# =============================================================================
+# Leave blank to disable invoice and password-reset email delivery.
-# Email delivery via Resend (optional)
-# Leave blank to disable invoice/password-reset email delivery.
RESEND_API_KEY=
RESEND_DOMAIN=
-# Analytics via Umami (optional)
-# Leave website ID blank to disable analytics.
+# =============================================================================
+# Analytics — Umami (optional)
+# =============================================================================
+# Leave website ID blank to disable.
+
NEXT_PUBLIC_UMAMI_WEBSITE_ID=
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.umami.is/script.js
-# Block new email/password registrations (optional)
+# =============================================================================
+# Access control (optional)
+# =============================================================================
+
+# Block new email/password registrations. Use literal true or false.
# DISABLE_SIGNUPS=true
-# SSO via Authentik OIDC (optional)
+# Bearer token for POST /api/cron/generate-recurring (recurring invoice cron).
+# CRON_SECRET=
+
+# =============================================================================
+# SSO — Authentik OIDC (optional)
+# =============================================================================
+# Set NEXT_PUBLIC_AUTHENTIK_ENABLED=true and rebuild the image to show SSO on
+# sign-in. Server secrets are runtime-only (no rebuild needed for secrets).
+
NEXT_PUBLIC_AUTHENTIK_ENABLED=false
AUTHENTIK_ISSUER=
AUTHENTIK_CLIENT_ID=
AUTHENTIK_CLIENT_SECRET=
+# Optional extra trusted origin for better-auth (defaults derived from issuer).
AUTHENTIK_ORIGIN=
+
+# =============================================================================
+# Advanced / CI (usually unset)
+# =============================================================================
+
+# Skip Zod env validation during `next build` (set automatically in Dockerfile).
+# SKIP_ENV_VALIDATION=1
diff --git a/Dockerfile b/Dockerfile
index b30892f..4c29e43 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -6,7 +6,10 @@ FROM base AS install
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile
-FROM base AS build
+# Next.js build must run on Node — Bun 1.3.x can SIGSEGV on Linux arm64 during
+# the "Collecting page data" worker phase (oven-sh/bun#...). Runtime stays on Bun.
+FROM node:22-bookworm-slim AS build
+WORKDIR /usr/src/app
COPY --from=install /usr/src/app/node_modules node_modules
COPY . .
@@ -15,19 +18,17 @@ ARG BETTER_AUTH_URL=http://localhost:3000
# Low-memory Docker build profile:
# - disable React Compiler (saves compile RAM; prod image still runs fine without it)
-# - skip eslint/tsc inside `next build` (run `bun run check` in CI instead)
-# - cap Node heap at 2GB (raise Docker/Colima memory if this still OOMs)
+# - skip tsc inside `next build` (run `bun run check` in CI instead)
ENV DOCKER_BUILD=1 \
DISABLE_REACT_COMPILER=1 \
NODE_ENV=production \
SKIP_ENV_VALIDATION=1 \
NEXT_TELEMETRY_DISABLED=1 \
- NODE_OPTIONS=--max-old-space-size=2048 \
BETTER_AUTH_URL=${BETTER_AUTH_URL} \
NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL} \
AUTH_SECRET=docker-build-placeholder-secret-do-not-use \
DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres
-RUN bun run build
+RUN node ./node_modules/next/dist/bin/next build
FROM base AS release
ENV NODE_ENV=production \
diff --git a/README.md b/README.md
index 5cf99a2..97183b4 100644
--- a/README.md
+++ b/README.md
@@ -94,7 +94,7 @@ The production compose file runs the Next.js app and PostgreSQL.
**Container startup** runs `bun migrate.ts && bun run start` (see `Dockerfile`). Drizzle only applies **pending** migrations — safe to run on every restart; already-applied migrations are skipped.
-The Docker **build** uses a low-memory profile (React Compiler off, webpack memory optimizations, 2GB Node heap cap). Give Docker/Colima at least **3–4GB RAM** for builds. If it still OOMs, raise VM memory (`colima start --memory 6`) — avoid `--no-cache` unless debugging.
+The Docker **build** runs `next build` on **Node 22** (Bun can crash on Linux arm64 during the page-data worker phase). The **runtime** image still uses Bun for migrations and `next start`. `docker-compose.yml` does not set container memory or CPU limits — containers can use whatever the Docker host provides.
### 1. Configure
@@ -128,7 +128,7 @@ docker compose up -d --build
`--build` is important. A plain `docker compose up -d` reuses the existing image and **does not** pick up new code from `git pull`.
-App listens on `${WEB_PORT:-3000}`. Postgres stays on the internal compose network.
+App listens on `${WEB_PORT:-${PORT:-3000}}` on the host (container port is always 3000). Postgres stays on the internal compose network.
### 3. Updating an existing deploy
diff --git a/docker-compose.yml b/docker-compose.yml
index f77a5aa..c8bed66 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -24,7 +24,7 @@ services:
AUTHENTIK_CLIENT_SECRET: ${AUTHENTIK_CLIENT_SECRET:-}
AUTHENTIK_ORIGIN: ${AUTHENTIK_ORIGIN:-}
ports:
- - "${WEB_PORT:-3000}:3000"
+ - "${WEB_PORT:-${PORT:-3000}}:3000"
depends_on:
db:
condition: service_healthy
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 40c3ffc..a3e758d 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -186,7 +186,7 @@ Validated in `src/env.js`. See `.env.example`.
| `docker-compose.yml` | Deploy: `app` + `db` (Postgres internal); copy `.env.example` → `.env` |
| `docker-compose.dev.yml` | Local dev: Postgres only, port `${POSTGRES_PORT:-5432}` |
-App image built from `Dockerfile`. Container `CMD`: `bun migrate.ts && bun run start` (migrations then `next start` on port 3000). Docker builds disable React Compiler and use `experimental.webpackMemoryOptimizations` to reduce peak RAM.
+App image built from `Dockerfile`. Container `CMD`: `bun migrate.ts && bun run start` (migrations then `next start` on port 3000). Docker builds run `next build` on Node 22 (not Bun) to avoid arm64 worker crashes; runtime stays on Bun. Docker builds disable React Compiler and use `experimental.webpackMemoryOptimizations` to reduce peak RAM.
Set `BETTER_AUTH_URL` and `NEXT_PUBLIC_APP_URL` to the public hostname before deploy. Rebuild the image when changing `NEXT_PUBLIC_*` build-time vars.
diff --git a/next.config.js b/next.config.js
index 5a46c89..eeef5bf 100644
--- a/next.config.js
+++ b/next.config.js
@@ -16,11 +16,11 @@ const config = {
experimental: {
webpackMemoryOptimizations: true,
},
- // Skip duplicate typecheck/eslint during Docker `next build` to lower peak memory.
+ // Skip duplicate typecheck during Docker `next build` to lower peak memory.
+ // Lint separately via `bun run lint` / `bun run check`.
...(isDockerBuild
? {
typescript: { ignoreBuildErrors: true },
- eslint: { ignoreDuringBuilds: true },
}
: {}),
};
diff --git a/src/app/(legal)/layout.tsx b/src/app/(legal)/layout.tsx
index 812bb12..b9f6005 100644
--- a/src/app/(legal)/layout.tsx
+++ b/src/app/(legal)/layout.tsx
@@ -1,5 +1,6 @@
import type { Metadata } from "next";
+import { MarketingProviders } from "~/components/providers/marketing-providers";
import { brand } from "~/lib/branding";
export const metadata: Metadata = {
@@ -14,5 +15,5 @@ export default function LegalLayout({
}: {
children: React.ReactNode;
}) {
- return children;
+ return {children};
}
diff --git a/src/app/(marketing)/layout.tsx b/src/app/(marketing)/layout.tsx
new file mode 100644
index 0000000..18ceb12
--- /dev/null
+++ b/src/app/(marketing)/layout.tsx
@@ -0,0 +1,9 @@
+import { MarketingProviders } from "~/components/providers/marketing-providers";
+
+export default function MarketingLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ return {children};
+}
diff --git a/src/app/(marketing)/page.tsx b/src/app/(marketing)/page.tsx
new file mode 100644
index 0000000..4b8e5fa
--- /dev/null
+++ b/src/app/(marketing)/page.tsx
@@ -0,0 +1,14 @@
+import { LandingPage } from "~/components/marketing/landing-page";
+import { env } from "~/env";
+
+export const dynamic = "force-dynamic";
+
+export default function HomePage() {
+ const allowRegistration = env.DISABLE_SIGNUPS !== true;
+
+ return (
+
+
+
+ );
+}
diff --git a/src/app/auth/layout.tsx b/src/app/auth/layout.tsx
new file mode 100644
index 0000000..f300b8f
--- /dev/null
+++ b/src/app/auth/layout.tsx
@@ -0,0 +1,9 @@
+import { MarketingProviders } from "~/components/providers/marketing-providers";
+
+export default function AuthLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ return {children};
+}
diff --git a/src/app/auth/signin/page.tsx b/src/app/auth/signin/page.tsx
index 138c61a..1d2b5ff 100644
--- a/src/app/auth/signin/page.tsx
+++ b/src/app/auth/signin/page.tsx
@@ -4,7 +4,13 @@ import { SignInForm } from "./signin-form";
export default function SignInPage() {
return (
- Loading...}>
+
+ Loading…
+
+ }
+ >
);
diff --git a/src/app/auth/signin/signin-form.tsx b/src/app/auth/signin/signin-form.tsx
index ae9040f..0ee66f3 100644
--- a/src/app/auth/signin/signin-form.tsx
+++ b/src/app/auth/signin/signin-form.tsx
@@ -1,17 +1,21 @@
"use client";
-import { useState, Suspense } from "react";
+import { useState } from "react";
+import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
-import { authClient } from "~/lib/auth-client";
-import { Card, CardContent } from "~/components/ui/card";
-import { Input } from "~/components/ui/input";
-import { Button } from "~/components/ui/button";
-import { Label } from "~/components/ui/label";
-import { toast } from "sonner";
-import { Logo } from "~/components/branding/logo";
+import { ArrowRight, Lock, Mail, Shield } from "lucide-react";
+import {
+ AuthCard,
+ AuthCardHeader,
+ AuthPageShell,
+} from "~/components/auth/auth-page-shell";
import { LegalAgreementNotice } from "~/components/legal/legal-links";
+import { Button } from "~/components/ui/button";
+import { Input } from "~/components/ui/input";
+import { Label } from "~/components/ui/label";
import { env } from "~/env";
-import { Mail, Lock, ArrowRight, Shield } from "lucide-react";
+import { authClient } from "~/lib/auth-client";
+import { toast } from "sonner";
interface SignInFormProps {
allowRegistration: boolean;
@@ -41,11 +45,12 @@ export function SignInForm({ allowRegistration }: SignInFormProps) {
? error.message
: "Invalid email or password",
);
- } else {
- toast.success("Signed in successfully!");
- router.push(callbackUrl);
- router.refresh();
+ return;
}
+
+ toast.success("Signed in successfully!");
+ router.push(callbackUrl);
+ router.refresh();
}
async function handleSocialSignIn() {
@@ -62,127 +67,108 @@ export function SignInForm({ allowRegistration }: SignInFormProps) {
}
return (
-
-
-
-
-
+
+
+
-
-
-
-
-
-
-
Welcome back
-
Sign in to your account
+ {signupDisabled && (
+
+ New account registration is currently disabled.
+
+ )}
+
+ {authentikEnabled && (
+
+
+
+
+
+
+
+
+ or
+
-
- {signupDisabled && (
-
- New account registration is currently disabled.
-