diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c267a33 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +node_modules +dist +.git +.gitignore +.DS_Store +*.log +.vite +index.original.html +.claude +.vscode +.idea diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d7f82e5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +# syntax=docker/dockerfile:1.7 +# Pin both stages to specific Alpine-based releases. Recommend layering +# `@sha256:` in Portainer's stack editor for full digest pinning. + +FROM node:22.11.0-alpine3.20 AS build +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci --no-audit --no-fund +COPY . . +RUN npm run build + +FROM nginx:1.27.2-alpine3.20 AS runtime +COPY --from=build /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD wget -q --spider http://127.0.0.1/ || exit 1 +CMD ["nginx", "-g", "daemon off;"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..28d3e33 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,16 @@ +services: + kammergut: + build: + context: . + dockerfile: Dockerfile + image: kammergut:0.1.0 + container_name: kammergut + restart: unless-stopped + ports: + - "4422:80" + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1/"] + interval: 30s + timeout: 3s + retries: 3 + start_period: 5s diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..f5fd4f4 --- /dev/null +++ b/nginx.conf @@ -0,0 +1,44 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + # Security headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always; + + # Hashed asset bundles — long cache, immutable + location /assets/ { + expires 1y; + add_header Cache-Control "public, immutable"; + try_files $uri =404; + } + + # Public images and other static files + location ~* \.(?:jpg|jpeg|png|gif|webp|svg|ico|woff2?|ttf)$ { + expires 30d; + add_header Cache-Control "public"; + try_files $uri =404; + } + + # SPA fallback — any non-asset path serves index.html so future + # client-side routing works without 404s. + location / { + try_files $uri $uri/ /index.html; + } + + # Don't serve dotfiles + location ~ /\. { + deny all; + access_log off; + log_not_found off; + } + + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml; +}