diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8ca8c3e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,28 @@ +.git +.gitignore +.github +.claude +.kilocode +.vscode +.idea +.DS_Store + +node_modules +dist +tsconfig.tsbuildinfo + +.env +.env.* +!.env.example + +npm-debug.log* +yarn-debug.log* +pnpm-debug.log* + +Dockerfile +.dockerignore +netlify.toml +README.md +doc +MIGRATION_BACKUP.md +MIGRATION_COMPLETE.md diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..577a398 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,27 @@ +# syntax=docker/dockerfile:1.7 + +FROM node:24-alpine AS build +WORKDIR /app + +RUN corepack enable + +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +RUN pnpm install --frozen-lockfile + +COPY . . +RUN pnpm run build + + +FROM nginx:1.27-alpine AS runtime + +RUN rm /etc/nginx/conf.d/default.conf +COPY nginx.conf /etc/nginx/conf.d/default.conf + +COPY --from=build /app/dist /usr/share/nginx/html + +EXPOSE 80 + +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD wget -qO- http://127.0.0.1/healthz || exit 1 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..be0290b --- /dev/null +++ b/nginx.conf @@ -0,0 +1,78 @@ +server { + listen 80 default_server; + listen [::]:80 default_server; + + server_name _; + root /usr/share/nginx/html; + index index.html; + + # Gzip + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_proxied any; + gzip_comp_level 6; + gzip_types + text/plain + text/css + text/xml + text/javascript + application/javascript + application/json + application/xml + application/xml+rss + application/manifest+json + image/svg+xml; + + # Security headers + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + # Healthcheck endpoint for Coolify / Docker + location = /healthz { + access_log off; + add_header Content-Type text/plain; + return 200 "ok\n"; + } + + # Service worker must never be cached (PWA updates rely on this) + location = /sw.js { + add_header Cache-Control "no-cache, no-store, must-revalidate"; + add_header Pragma "no-cache"; + expires 0; + try_files $uri =404; + } + + location = /registerSW.js { + add_header Cache-Control "no-cache, no-store, must-revalidate"; + expires 0; + try_files $uri =404; + } + + location = /manifest.webmanifest { + add_header Cache-Control "no-cache"; + types { } default_type "application/manifest+json"; + try_files $uri =404; + } + + # Hashed build assets — safe to cache for a year + location /assets/ { + access_log off; + add_header Cache-Control "public, max-age=31536000, immutable"; + try_files $uri =404; + } + + # Static images / icons — moderate cache + location ~* \.(?:png|jpg|jpeg|gif|ico|svg|webp|avif|woff2?)$ { + access_log off; + add_header Cache-Control "public, max-age=604800"; + try_files $uri =404; + } + + # SPA fallback — every other route serves index.html (uncached) + location / { + add_header Cache-Control "no-cache"; + try_files $uri $uri/ /index.html; + } +}