create-tigra 3.0.2 → 3.0.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.
Files changed (38) hide show
  1. package/README.md +4 -0
  2. package/package.json +1 -1
  3. package/template/client/.env.example +19 -0
  4. package/template/client/Dockerfile +14 -4
  5. package/template/client/next.config.ts +7 -42
  6. package/template/client/package-lock.json +1896 -146
  7. package/template/client/package.json +3 -1
  8. package/template/client/scripts/next-dev.cjs +47 -0
  9. package/template/client/src/app/layout.tsx +10 -3
  10. package/template/client/src/app/providers.tsx +15 -2
  11. package/template/client/src/instrumentation-client.ts +38 -0
  12. package/template/client/src/instrumentation.ts +42 -0
  13. package/template/client/src/lib/env.ts +13 -2
  14. package/template/client/src/middleware.ts +105 -18
  15. package/template/server/.env.example +22 -1
  16. package/template/server/.env.example.production +16 -1
  17. package/template/server/Dockerfile +16 -8
  18. package/template/server/package-lock.json +2136 -86
  19. package/template/server/package.json +6 -0
  20. package/template/server/src/app.ts +30 -11
  21. package/template/server/src/config/env.ts +23 -2
  22. package/template/server/src/config/rate-limit.config.ts +6 -0
  23. package/template/server/src/libs/logger.ts +15 -0
  24. package/template/server/src/libs/observability/sentry.ts +42 -0
  25. package/template/server/src/libs/requestLogger.ts +8 -2
  26. package/template/server/src/libs/storage/file-storage.service.ts +144 -16
  27. package/template/server/src/modules/admin/__tests__/admin.integration.test.ts +128 -0
  28. package/template/server/src/modules/auth/__tests__/auth.integration.test.ts +138 -0
  29. package/template/server/src/modules/auth/__tests__/auth.service.test.ts +25 -41
  30. package/template/server/src/modules/auth/auth.repo.ts +0 -12
  31. package/template/server/src/modules/auth/auth.service.ts +29 -57
  32. package/template/server/src/modules/files/__tests__/files.integration.test.ts +157 -0
  33. package/template/server/src/modules/files/files.controller.ts +180 -0
  34. package/template/server/src/modules/files/files.routes.ts +46 -0
  35. package/template/server/src/server.ts +6 -0
  36. package/template/server/src/test/integration.setup.ts +170 -0
  37. package/template/server/vitest.config.ts +10 -1
  38. package/template/server/vitest.integration.config.ts +50 -0
@@ -3,16 +3,18 @@
3
3
  "version": "0.1.0",
4
4
  "private": true,
5
5
  "scripts": {
6
- "dev": "next dev",
6
+ "dev": "node ./scripts/next-dev.cjs",
7
7
  "build": "next build",
8
8
  "start": "next start",
9
9
  "lint": "eslint src/",
10
10
  "typecheck": "tsc --noEmit",
11
+ "audit": "npm audit --audit-level=high",
11
12
  "generate:env": "node -e \"import('fs').then(f=>{if(f.existsSync('.env'))console.log('.env already exists, skipping');else{f.copyFileSync('.env.example','.env');console.log('.env created from .env.example')}})\""
12
13
  },
13
14
  "dependencies": {
14
15
  "@hookform/resolvers": "^5.2.2",
15
16
  "@reduxjs/toolkit": "^2.11.2",
17
+ "@sentry/nextjs": "^10.62.0",
16
18
  "@tanstack/react-query": "^5.90.21",
17
19
  "axios": "^1.13.5",
18
20
  "class-variance-authority": "^0.7.1",
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ const { spawn } = require("node:child_process");
5
+
6
+ const DEFAULT_MAX_OLD_SPACE_SIZE = "768";
7
+ const nextBin = require.resolve("next/dist/bin/next");
8
+
9
+ function applyOldSpaceLimit(nodeOptions, sizeMb) {
10
+ if (/(^|\s)--max[-_]old[-_]space[-_]size(=|\s|$)/.test(nodeOptions)) {
11
+ return nodeOptions;
12
+ }
13
+
14
+ return [nodeOptions, `--max-old-space-size=${sizeMb}`].filter(Boolean).join(" ").trim();
15
+ }
16
+
17
+ const env = { ...process.env };
18
+ env.NEXT_DISABLE_MEM_OVERRIDE = "1";
19
+ env.NODE_OPTIONS = applyOldSpaceLimit(
20
+ env.NODE_OPTIONS || "",
21
+ env.NEXT_DEV_MAX_OLD_SPACE_SIZE || DEFAULT_MAX_OLD_SPACE_SIZE,
22
+ );
23
+
24
+ const args = [
25
+ nextBin,
26
+ "dev",
27
+ "--webpack",
28
+ "--disable-source-maps",
29
+ "--no-server-fast-refresh",
30
+ ...process.argv.slice(2),
31
+ ];
32
+
33
+ const child = spawn(process.execPath, args, { stdio: "inherit", env });
34
+
35
+ child.on("error", (error) => {
36
+ console.error(error);
37
+ process.exit(1);
38
+ });
39
+
40
+ child.on("exit", (code, signal) => {
41
+ if (signal) {
42
+ process.kill(process.pid, signal);
43
+ return;
44
+ }
45
+
46
+ process.exit(code ?? 0);
47
+ });
@@ -1,3 +1,4 @@
1
+ import { headers } from 'next/headers';
1
2
  import type { Metadata, Viewport } from 'next';
2
3
  import type React from 'react';
3
4
  import { Inter, JetBrains_Mono } from 'next/font/google';
@@ -27,15 +28,21 @@ export const viewport: Viewport = {
27
28
  viewportFit: 'cover',
28
29
  };
29
30
 
30
- export default function RootLayout({
31
+ export default async function RootLayout({
31
32
  children,
32
33
  }: Readonly<{
33
34
  children: React.ReactNode;
34
- }>): React.ReactElement {
35
+ }>): Promise<React.ReactElement> {
36
+ // Read the per-request nonce set by middleware. next-themes injects an inline
37
+ // anti-FOUC <script> via dangerouslySetInnerHTML that Next does NOT auto-nonce,
38
+ // so under our 'strict-dynamic' CSP it must be passed the nonce explicitly or
39
+ // the browser refuses it (causing a flash / hydration mismatch).
40
+ const nonce = (await headers()).get('x-nonce') ?? undefined;
41
+
35
42
  return (
36
43
  <html lang="en" suppressHydrationWarning>
37
44
  <body className={`${inter.variable} ${jetbrainsMono.variable} font-sans antialiased`}>
38
- <Providers>{children}</Providers>
45
+ <Providers nonce={nonce}>{children}</Providers>
39
46
  </body>
40
47
  </html>
41
48
  );
@@ -11,7 +11,15 @@ import { Toaster } from 'sonner';
11
11
  import { store } from '@/store';
12
12
  import { AuthInitializer } from '@/features/auth/components/AuthInitializer';
13
13
 
14
- export function Providers({ children }: { children: React.ReactNode }): React.ReactElement {
14
+ export function Providers({
15
+ children,
16
+ nonce,
17
+ }: {
18
+ children: React.ReactNode;
19
+ // Per-request CSP nonce from middleware (via the root layout). Forwarded to
20
+ // next-themes so its inline anti-FOUC script is allowed under 'strict-dynamic'.
21
+ nonce?: string;
22
+ }): React.ReactElement {
15
23
  const [queryClient] = useState(
16
24
  () =>
17
25
  new QueryClient({
@@ -30,7 +38,12 @@ export function Providers({ children }: { children: React.ReactNode }): React.Re
30
38
  return (
31
39
  <ReduxProvider store={store}>
32
40
  <QueryClientProvider client={queryClient}>
33
- <ThemeProvider attribute="class" defaultTheme="light" enableColorScheme={false}>
41
+ <ThemeProvider
42
+ attribute="class"
43
+ defaultTheme="light"
44
+ enableColorScheme={false}
45
+ nonce={nonce}
46
+ >
34
47
  <AuthInitializer>
35
48
  {children}
36
49
  </AuthInitializer>
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Browser instrumentation.
3
+ *
4
+ * Keep local dev cold: do not import @sentry/nextjs unless production has a
5
+ * public DSN. This avoids compiling the Sentry SDK for every local dev server.
6
+ */
7
+ type SentryNext = typeof import('@sentry/nextjs');
8
+ type RouterTransitionStart = SentryNext['captureRouterTransitionStart'];
9
+
10
+ const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN;
11
+ const sentryEnabled = process.env.NODE_ENV === 'production' && Boolean(dsn);
12
+
13
+ let sentryPromise: Promise<SentryNext> | null = null;
14
+
15
+ function loadSentry(): Promise<SentryNext | null> {
16
+ if (!sentryEnabled || !dsn) return Promise.resolve(null);
17
+
18
+ sentryPromise ??= import('@sentry/nextjs').then((Sentry) => {
19
+ Sentry.init({
20
+ dsn,
21
+ tracesSampleRate: process.env.NEXT_PUBLIC_SENTRY_TRACES_SAMPLE_RATE
22
+ ? Number(process.env.NEXT_PUBLIC_SENTRY_TRACES_SAMPLE_RATE)
23
+ : 0.1,
24
+ });
25
+
26
+ return Sentry;
27
+ });
28
+
29
+ return sentryPromise;
30
+ }
31
+
32
+ void loadSentry();
33
+
34
+ export const onRouterTransitionStart = ((...args: Parameters<RouterTransitionStart>) => {
35
+ void loadSentry().then((Sentry) => {
36
+ Sentry?.captureRouterTransitionStart(...args);
37
+ });
38
+ }) as RouterTransitionStart;
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Server/Edge runtime instrumentation.
3
+ *
4
+ * Keep local dev cold: importing @sentry/nextjs at module top-level makes Next
5
+ * compile the Sentry SDK before the first page, even with no DSN.
6
+ */
7
+ type SentryNext = typeof import('@sentry/nextjs');
8
+ type RequestErrorHandler = SentryNext['captureRequestError'];
9
+
10
+ const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN ?? process.env.SENTRY_DSN;
11
+ const sentryEnabled = process.env.NODE_ENV === 'production' && Boolean(dsn);
12
+
13
+ const tracesSampleRate = process.env.NEXT_PUBLIC_SENTRY_TRACES_SAMPLE_RATE
14
+ ? Number(process.env.NEXT_PUBLIC_SENTRY_TRACES_SAMPLE_RATE)
15
+ : 0.1;
16
+
17
+ let sentryPromise: Promise<SentryNext> | null = null;
18
+
19
+ function loadSentry(): Promise<SentryNext | null> {
20
+ if (!sentryEnabled) return Promise.resolve(null);
21
+ sentryPromise ??= import('@sentry/nextjs');
22
+ return sentryPromise;
23
+ }
24
+
25
+ export async function register(): Promise<void> {
26
+ const Sentry = await loadSentry();
27
+ if (!Sentry || !dsn) return;
28
+
29
+ if (process.env.NEXT_RUNTIME === 'nodejs' || process.env.NEXT_RUNTIME === 'edge') {
30
+ Sentry.init({
31
+ dsn,
32
+ tracesSampleRate,
33
+ environment: process.env.SENTRY_ENVIRONMENT ?? process.env.NODE_ENV,
34
+ });
35
+ }
36
+ }
37
+
38
+ export const onRequestError = (async (...args: Parameters<RequestErrorHandler>) => {
39
+ const Sentry = await loadSentry();
40
+ if (!Sentry) return;
41
+ return Sentry.captureRequestError(...args);
42
+ }) as RequestErrorHandler;
@@ -1,19 +1,30 @@
1
1
  import { z } from 'zod';
2
2
 
3
- // Every field MUST have a .default() so the dev fallback (envSchema.parse({}))
4
- // can produce valid defaults when env vars are missing in development.
3
+ // Every required field MUST have a .default() so the dev fallback
4
+ // (envSchema.parse({})) can produce valid defaults when env vars are missing in
5
+ // development. Genuinely optional integrations (e.g. Sentry) use .optional()
6
+ // instead — unset means "disabled", and the build must pass with them absent.
5
7
  const envSchema = z.object({
6
8
  NEXT_PUBLIC_API_BASE_URL: z
7
9
  .string()
8
10
  .url('NEXT_PUBLIC_API_BASE_URL must be a valid URL')
9
11
  .default('http://localhost:8000/api/v1'),
10
12
  NEXT_PUBLIC_APP_NAME: z.string().default('My App'),
13
+
14
+ // --- Error Tracking (Optional, Sentry) ---
15
+ // Inert without a DSN: instrumentation only inits Sentry when this is set.
16
+ // Optional so `next build` passes with it unset. Never hardcode a DSN.
17
+ NEXT_PUBLIC_SENTRY_DSN: z.string().url().optional(),
18
+ // Fraction of transactions sampled for browser performance tracing (0..1).
19
+ NEXT_PUBLIC_SENTRY_TRACES_SAMPLE_RATE: z.coerce.number().min(0).max(1).optional(),
11
20
  });
12
21
 
13
22
  function validateEnv(): z.infer<typeof envSchema> {
14
23
  const result = envSchema.safeParse({
15
24
  NEXT_PUBLIC_API_BASE_URL: process.env.NEXT_PUBLIC_API_BASE_URL,
16
25
  NEXT_PUBLIC_APP_NAME: process.env.NEXT_PUBLIC_APP_NAME,
26
+ NEXT_PUBLIC_SENTRY_DSN: process.env.NEXT_PUBLIC_SENTRY_DSN,
27
+ NEXT_PUBLIC_SENTRY_TRACES_SAMPLE_RATE: process.env.NEXT_PUBLIC_SENTRY_TRACES_SAMPLE_RATE,
17
28
  });
18
29
 
19
30
  if (!result.success) {
@@ -14,9 +14,94 @@ function isTokenExpired(token: string): boolean {
14
14
  }
15
15
  }
16
16
 
17
+ /**
18
+ * Derive the origin (scheme://host[:port]) of a URL, falling back to the input
19
+ * when it can't be parsed. Used to turn NEXT_PUBLIC_API_BASE_URL (which may
20
+ * carry a path like .../api/v1) and the Sentry DSN into bare CSP source values.
21
+ */
22
+ function toOrigin(url: string | undefined): string | null {
23
+ if (!url) return null;
24
+ try {
25
+ return new URL(url).origin;
26
+ } catch {
27
+ return null;
28
+ }
29
+ }
30
+
31
+ const apiOrigin = toOrigin(process.env.NEXT_PUBLIC_API_BASE_URL) ?? 'http://localhost:8000';
32
+ // When Sentry is configured, its browser SDK POSTs envelopes to the DSN origin;
33
+ // it must be allowed in connect-src or every error report is blocked by CSP.
34
+ const sentryOrigin = toOrigin(process.env.NEXT_PUBLIC_SENTRY_DSN);
35
+
36
+ /**
37
+ * Build the per-request Content-Security-Policy. A fresh nonce is minted for
38
+ * every request and threaded onto Next's own scripts (see middleware below), so
39
+ * 'strict-dynamic' can trust them while refusing any attacker-injected inline
40
+ * script that lacks the nonce.
41
+ *
42
+ * DEV relaxes script-src with 'unsafe-eval' (Next's HMR/react-refresh evals) and
43
+ * style-src with 'unsafe-inline' (the dev overlay injects inline styles). PROD
44
+ * is strict. Style-src keeps 'unsafe-inline' in PROD too: Tailwind v4 and some
45
+ * libraries emit inline <style>/style="" that a nonce cannot cover and that a
46
+ * strict style-src would REFUSE, breaking the page. Inline styles cannot
47
+ * exfiltrate data the way inline scripts can, so this is an accepted tradeoff —
48
+ * the high-value half (the strict, nonce-gated script-src) stays intact.
49
+ */
50
+ function buildCsp(nonce: string, isProd: boolean): string {
51
+ const connectSrc = ["'self'", apiOrigin, sentryOrigin].filter(Boolean).join(' ');
52
+
53
+ const scriptSrc = isProd
54
+ ? `'self' 'nonce-${nonce}' 'strict-dynamic' https:`
55
+ : `'self' 'nonce-${nonce}' 'strict-dynamic' 'unsafe-eval' https:`;
56
+
57
+ // 'unsafe-inline' in style-src in BOTH modes (see note above).
58
+ const styleSrc = `'self' 'unsafe-inline'`;
59
+
60
+ return [
61
+ `default-src 'self'`,
62
+ `script-src ${scriptSrc}`,
63
+ `style-src ${styleSrc}`,
64
+ `img-src 'self' blob: data: ${apiOrigin}`,
65
+ `font-src 'self'`,
66
+ `connect-src ${connectSrc}`,
67
+ `object-src 'none'`,
68
+ `base-uri 'none'`,
69
+ `form-action 'self'`,
70
+ `frame-ancestors 'none'`,
71
+ `upgrade-insecure-requests`,
72
+ ].join('; ');
73
+ }
74
+
75
+ /**
76
+ * Attach the static security headers shared by every response. HSTS is
77
+ * deliberately NOT set here — it is managed at the Cloudflare edge.
78
+ */
79
+ function applySecurityHeaders(response: NextResponse): NextResponse {
80
+ response.headers.set('X-Content-Type-Options', 'nosniff');
81
+ response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
82
+ // Redundant with frame-ancestors 'none' but harmless for legacy browsers.
83
+ response.headers.set('X-Frame-Options', 'DENY');
84
+ return response;
85
+ }
86
+
17
87
  export function middleware(request: NextRequest): NextResponse {
18
88
  const { pathname } = request.nextUrl;
19
89
 
90
+ const isProd = process.env.NODE_ENV === 'production';
91
+
92
+ // --- Per-request nonce + CSP (canonical Next.js pattern) ---
93
+ // base64-encoded random UUID. Minted fresh per request so 'strict-dynamic'
94
+ // only trusts scripts we (and Next) emit this request.
95
+ const nonce = Buffer.from(crypto.randomUUID()).toString('base64');
96
+ const cspHeader = buildCsp(nonce, isProd);
97
+
98
+ // Propagate the nonce + CSP on the REQUEST headers. Next reads the CSP from
99
+ // the request to extract the nonce and stamps it onto its own <script> tags;
100
+ // without this, strict-dynamic would refuse Next's scripts and break the page.
101
+ const requestHeaders = new Headers(request.headers);
102
+ requestHeaders.set('x-nonce', nonce);
103
+ requestHeaders.set('content-security-policy', cspHeader);
104
+
20
105
  // NOTE: This middleware only checks COOKIE PRESENCE (and a best-effort,
21
106
  // unverified exp claim) for UX-level routing — redirecting users who are
22
107
  // obviously logged out away from protected pages. It does NOT verify the
@@ -29,36 +114,38 @@ export function middleware(request: NextRequest): NextResponse {
29
114
  const isProtectedPath = protectedPaths.some((path) => pathname.startsWith(path));
30
115
 
31
116
  if (isProtectedPath) {
32
- // No access_token AND no auth_session — truly unauthenticated
117
+ // No access_token AND no auth_session — truly unauthenticated.
33
118
  if (!accessToken && !authSession) {
34
119
  const loginUrl = new URL('/login', request.url);
35
120
  loginUrl.searchParams.set('from', pathname);
36
- return NextResponse.redirect(loginUrl);
121
+ const redirect = NextResponse.redirect(loginUrl);
122
+ redirect.headers.set('content-security-policy', cspHeader);
123
+ return applySecurityHeaders(redirect);
37
124
  }
125
+ }
38
126
 
39
- // No access_token BUT auth_session exists — session is alive,
40
- // let through so client-side can do getMe() → 401 → refresh → retry
41
- if (!accessToken && authSession) {
42
- return NextResponse.next();
43
- }
127
+ // Single .next() response carrying the propagated request headers, the CSP
128
+ // (also on the response, for the browser), and the static security headers.
129
+ const response = NextResponse.next({ request: { headers: requestHeaders } });
130
+ response.headers.set('content-security-policy', cspHeader);
131
+ applySecurityHeaders(response);
44
132
 
45
- // Expired access_token — delete stale cookie, let through for client-side refresh
133
+ if (isProtectedPath) {
134
+ // No access_token BUT auth_session exists — session is alive, let through
135
+ // so client-side can do getMe() → 401 → refresh → retry.
136
+ // (response already passes through; nothing extra needed here.)
137
+
138
+ // Expired access_token — delete stale cookie, let through for client refresh.
46
139
  if (accessToken && isTokenExpired(accessToken)) {
47
- const response = NextResponse.next();
48
140
  response.cookies.delete('access_token');
49
- return response;
50
141
  }
51
-
52
- return NextResponse.next();
53
142
  }
54
143
 
55
- return NextResponse.next();
144
+ return response;
56
145
  }
57
146
 
58
147
  export const config = {
59
- matcher: [
60
- '/dashboard/:path*',
61
- '/profile/:path*',
62
- '/admin/:path*',
63
- ],
148
+ // Apply security headers site-wide, but skip Next's static assets and the
149
+ // favicon (no CSP needed on immutable static files / images).
150
+ matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
64
151
  };
@@ -129,6 +129,18 @@ REQUIRE_USER_VERIFICATION=true
129
129
  # Maximum file upload size in MB (default: 10)
130
130
  MAX_FILE_SIZE_MB=10
131
131
 
132
+ # Two-tier upload storage. BOTH optional — leave unset to use the defaults
133
+ # (<cwd>/uploads/public and <cwd>/uploads/private).
134
+ # PUBLIC tier: served as static files at /uploads/ — viewable by ANYONE,
135
+ # including unauthenticated users (e.g. avatars). @fastify/static
136
+ # is scoped to ONLY this dir so it can never serve a private file.
137
+ # PRIVATE tier: lives OUTSIDE the static root; served ONLY through the
138
+ # auth-gated, owner-scoped route GET /api/v1/files/:filename.
139
+ # Both must live UNDER the Coolify uploads volume (/app/uploads/...) so files
140
+ # survive redeploys — see COOLIFY PERSISTENT STORAGE below.
141
+ # UPLOAD_PUBLIC_DIR=/app/uploads/public
142
+ # UPLOAD_PRIVATE_DIR=/app/uploads/private
143
+
132
144
  # COOLIFY PERSISTENT STORAGE (required for uploads to survive redeployments):
133
145
  # Go to your service in Coolify → Storages → Add Volume Mount:
134
146
  # Name: uploads (or <project-name>-uploads)
@@ -243,6 +255,15 @@ LOG_LEVEL=info
243
255
 
244
256
  # Sentry DSN for error tracking and monitoring
245
257
  # Get your DSN from: https://sentry.io/settings/projects/
246
- # Leave empty to disable Sentry
258
+ # Leave empty to disable Sentry — with no DSN, init is a no-op (fully inert).
259
+ # COOLIFY: Runtime only. Do NOT check "Available at Buildtime".
247
260
  # Example: SENTRY_DSN="https://examplePublicKey@o0.ingest.sentry.io/0"
248
261
  # SENTRY_DSN=""
262
+
263
+ # Fraction of transactions sampled for performance tracing (0.0–1.0, default 0.1).
264
+ # Only takes effect when SENTRY_DSN is set.
265
+ # SENTRY_TRACES_SAMPLE_RATE=0.1
266
+
267
+ # Environment tag attached to Sentry events (e.g. production, staging).
268
+ # Defaults to NODE_ENV when unset.
269
+ # SENTRY_ENVIRONMENT=""
@@ -122,6 +122,15 @@ REQUIRE_USER_VERIFICATION=true
122
122
  # Maximum file upload size in MB
123
123
  MAX_FILE_SIZE_MB=10
124
124
 
125
+ # Two-tier upload storage. BOTH optional — defaults are <cwd>/uploads/public and
126
+ # <cwd>/uploads/private. Keep them UNDER /app/uploads (the Coolify volume below)
127
+ # so files survive redeploys.
128
+ # PUBLIC tier: static files at /uploads/ — viewable by ANYONE (e.g. avatars).
129
+ # PRIVATE tier: OUTSIDE the static root; served ONLY via the auth-gated,
130
+ # owner-scoped route GET /api/v1/files/:filename.
131
+ # UPLOAD_PUBLIC_DIR=/app/uploads/public
132
+ # UPLOAD_PRIVATE_DIR=/app/uploads/private
133
+
125
134
  # COOLIFY PERSISTENT STORAGE (required for uploads to survive redeployments):
126
135
  # Go to your service in Coolify → Storages → Add Volume Mount:
127
136
  # Name: uploads (or <project-name>-uploads)
@@ -199,10 +208,16 @@ LOG_LEVEL=info
199
208
  # ERROR TRACKING
200
209
  # ===================================================================
201
210
 
202
- # Sentry for production error monitoring
211
+ # Sentry for production error monitoring (leave unset to disable — fully inert)
203
212
  # Get your DSN from: https://sentry.io/settings/projects/
204
213
  SENTRY_DSN="https://YOUR_PUBLIC_KEY@o0.ingest.sentry.io/YOUR_PROJECT_ID"
205
214
 
215
+ # Fraction of transactions sampled for performance tracing (0.0–1.0, default 0.1).
216
+ SENTRY_TRACES_SAMPLE_RATE=0.1
217
+
218
+ # Environment tag on Sentry events (defaults to NODE_ENV when unset).
219
+ SENTRY_ENVIRONMENT=production
220
+
206
221
  # ===================================================================
207
222
  # PRODUCTION CHECKLIST
208
223
  # ===================================================================
@@ -1,12 +1,16 @@
1
+ # syntax=docker/dockerfile:1
1
2
  # ===================================================================
2
3
  # Multi-stage Dockerfile for Production Deployment
3
4
  # Optimized for Coolify, Docker Compose, and Kubernetes
4
5
  # ===================================================================
6
+ # BuildKit cache mounts (--mount=type=cache) keep the npm download cache warm
7
+ # ACROSS builds, so a lockfile-change rebuild re-installs without re-downloading.
8
+ # Requires the `# syntax` line above; auto-enabled on Docker 23+ / any BuildKit.
5
9
 
6
10
  # ===================================================================
7
11
  # Stage 1: Dependencies (cached layer)
8
12
  # ===================================================================
9
- FROM node:20-alpine AS dependencies
13
+ FROM node:20-alpine@sha256:fb4cd12c85ee03686f6af5362a0b0d56d50c58a04632e6c0fb8363f609372293 AS dependencies
10
14
 
11
15
  # Install production dependencies only
12
16
  WORKDIR /app
@@ -15,7 +19,8 @@ WORKDIR /app
15
19
  COPY package.json package-lock.json* pnpm-lock.yaml* yarn.lock* ./
16
20
 
17
21
  # Install dependencies (use the lockfile that exists)
18
- RUN if [ -f pnpm-lock.yaml ]; then \
22
+ RUN --mount=type=cache,target=/root/.npm \
23
+ if [ -f pnpm-lock.yaml ]; then \
19
24
  npm install -g pnpm && pnpm install --prod --frozen-lockfile; \
20
25
  elif [ -f yarn.lock ]; then \
21
26
  yarn install --production --frozen-lockfile; \
@@ -32,13 +37,13 @@ RUN if [ -f pnpm-lock.yaml ]; then \
32
37
  # --no-save keeps package.json and the lockfile untouched. Because this stage
33
38
  # runs on the same Alpine (musl) base as the runtime stage, the engine binaries
34
39
  # downloaded here are the correct ones for production.
35
- RUN npm install --no-save --no-audit --no-fund \
40
+ RUN --mount=type=cache,target=/root/.npm npm install --no-save --no-audit --no-fund \
36
41
  "prisma@$(node -p "require('@prisma/client/package.json').version")"
37
42
 
38
43
  # ===================================================================
39
44
  # Stage 2: Build (compile TypeScript)
40
45
  # ===================================================================
41
- FROM node:20-alpine AS builder
46
+ FROM node:20-alpine@sha256:fb4cd12c85ee03686f6af5362a0b0d56d50c58a04632e6c0fb8363f609372293 AS builder
42
47
 
43
48
  # Override NODE_ENV to ensure devDependencies are installed.
44
49
  # Coolify may inject NODE_ENV=production as a build arg, which causes npm ci
@@ -50,7 +55,8 @@ WORKDIR /app
50
55
  # Copy package files and install ALL dependencies (including dev)
51
56
  COPY package.json package-lock.json* pnpm-lock.yaml* yarn.lock* ./
52
57
 
53
- RUN if [ -f pnpm-lock.yaml ]; then \
58
+ RUN --mount=type=cache,target=/root/.npm \
59
+ if [ -f pnpm-lock.yaml ]; then \
54
60
  npm install -g pnpm && pnpm install --frozen-lockfile; \
55
61
  elif [ -f yarn.lock ]; then \
56
62
  yarn install --frozen-lockfile; \
@@ -70,7 +76,7 @@ RUN npm run build
70
76
  # ===================================================================
71
77
  # Stage 3: Production Runtime
72
78
  # ===================================================================
73
- FROM node:20-alpine AS production
79
+ FROM node:20-alpine@sha256:fb4cd12c85ee03686f6af5362a0b0d56d50c58a04632e6c0fb8363f609372293 AS production
74
80
 
75
81
  # Install dumb-init for proper signal handling
76
82
  RUN apk add --no-cache dumb-init
@@ -96,8 +102,10 @@ COPY --from=builder --chown=nodejs:nodejs /app/package.json ./package.json
96
102
  # Copy Prisma files (needed for migrations)
97
103
  COPY --from=builder --chown=nodejs:nodejs /app/prisma ./prisma
98
104
 
99
- # Create uploads directory with correct ownership (must be before USER nodejs)
100
- RUN mkdir -p /app/uploads && chown nodejs:nodejs /app/uploads
105
+ # Create BOTH upload tiers with correct ownership (must be before USER nodejs).
106
+ # public/ is served as static files at /uploads/; private/ is served ONLY via
107
+ # the auth-gated route GET /api/v1/files/:filename. See file-storage.service.ts.
108
+ RUN mkdir -p /app/uploads/public /app/uploads/private && chown -R nodejs:nodejs /app/uploads
101
109
 
102
110
  # Switch to non-root user
103
111
  USER nodejs