diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0474f9e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,36 @@ +# ── Stage 1 : nothing to build, just copy static assets ────────────────────── +# Use nginx:alpine — ~8 MB, actively maintained, CVE-scanned by Docker Hub +FROM nginx:stable-alpine AS runtime + +# Drop all Linux capabilities the HTTP server doesn't need. +# Only CAP_NET_BIND_SERVICE is kept when binding to port 80; since we expose +# 8080 (>1024) and run as non-root, we can drop everything. +# These are set at runtime via docker-compose; the label is informational. +LABEL org.opencontainers.image.title="it_portfolio" \ + org.opencontainers.image.description="Static portfolio — nginx/alpine" \ + org.opencontainers.image.authors="Gauvain Boiché" + +# Replace the default nginx config with our hardened one +COPY nginx.conf /etc/nginx/nginx.conf + +# Copy static assets +COPY index.html /usr/share/nginx/html/index.html +COPY content/ /usr/share/nginx/html/content/ +COPY resources/ /usr/share/nginx/html/resources/ + +# Give the non-root nginx user ownership of what it needs at runtime. +# Temp files go to /tmp (1777 tmpfs), so no pre-creation needed. +RUN chown -R nginx:nginx \ + /var/log/nginx \ + /etc/nginx/nginx.conf \ + /usr/share/nginx/html \ + && chmod -R 755 /usr/share/nginx/html + +# Switch to the built-in unprivileged nginx user +USER nginx + +# Expose unprivileged port (no CAP_NET_BIND_SERVICE required) +EXPOSE 8080 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ + CMD wget -qO- http://localhost:8080/ || exit 1 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..82df8b7 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,49 @@ +services: + portfolio: + build: + context: . + dockerfile: Dockerfile + image: it_portfolio:latest + container_name: it_portfolio + + # ── Networking ────────────────────────────────────────────────────────── + ports: + - "8080:8080" + + # ── Resource limits (near-zero footprint for a static site) ───────────── + deploy: + resources: + limits: + cpus: "0.10" # 10 % of one core at most + memory: 32M + reservations: + cpus: "0.01" + memory: 8M + + # ── Hardening ─────────────────────────────────────────────────────────── + read_only: true # container filesystem is immutable + tmpfs: # /tmp is the only writable path nginx needs + - /tmp:size=16m,mode=1777 + + security_opt: + - no-new-privileges:true # prevent privilege escalation via setuid + cap_drop: + - ALL # drop every Linux capability… + # (no cap_add needed — port 8080 > 1024, user nginx, no raw sockets) + + # ── Lifecycle ─────────────────────────────────────────────────────────── + restart: unless-stopped + + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:8080/"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 5s + + # ── Observability ─────────────────────────────────────────────────────── + logging: + driver: json-file + options: + max-size: "5m" + max-file: "3" diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..9ae11fd --- /dev/null +++ b/nginx.conf @@ -0,0 +1,107 @@ +# Hardened nginx config for a static portfolio +# Runs as user 'nginx' (non-root), listens on port 8080 + +worker_processes auto; # one worker per CPU core +worker_rlimit_nofile 1024; # tight file-descriptor cap + +# Log to stdout/stderr so Docker can collect them +error_log /dev/stderr warn; +pid /tmp/nginx.pid; # writable by non-root user + +events { + worker_connections 512; + use epoll; + multi_accept on; +} + +http { + # ── Temp paths writable by the nginx user ─────────────────────────────── + # /tmp is a 1777 tmpfs — writable by any user, no subdirectory pre-creation needed + client_body_temp_path /tmp/client_temp; + proxy_temp_path /tmp/proxy_temp; + fastcgi_temp_path /tmp/fastcgi_temp; + uwsgi_temp_path /tmp/uwsgi_temp; + scgi_temp_path /tmp/scgi_temp; + + access_log /dev/stdout combined; + + # ── MIME types ────────────────────────────────────────────────────────── + include /etc/nginx/mime.types; + default_type application/octet-stream; + + # ── Performance ───────────────────────────────────────────────────────── + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 30; + keepalive_requests 100; + + # ── Compression ───────────────────────────────────────────────────────── + gzip on; + gzip_static on; + gzip_vary on; + gzip_min_length 256; + gzip_proxied any; + gzip_comp_level 5; + gzip_types + text/plain text/css text/javascript application/javascript + application/json application/xml image/svg+xml + font/woff font/woff2; + + # ── Security headers ──────────────────────────────────────────────────── + server_tokens off; # hide nginx version + + # ── Virtual host ──────────────────────────────────────────────────────── + server { + listen 8080; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + # Security headers sent on every response + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "DENY" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always; + add_header Content-Security-Policy + "default-src 'self'; \ + script-src 'self' 'unsafe-inline'; \ + style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; \ + font-src 'self' https://fonts.gstatic.com; \ + img-src 'self' data:; \ + connect-src 'self'; \ + frame-ancestors 'none';" + always; + + # Cache static assets aggressively, HTML never + location ~* \.(css|js|woff2?|ico|svg|png|jpg|jpeg|gif|webp)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + location / { + try_files $uri $uri/ /index.html; + add_header Cache-Control "no-cache, no-store, must-revalidate"; + } + + # Deny access to hidden files + location ~ /\. { + deny all; + } + + # Limit accepted HTTP methods + if ($request_method !~ ^(GET|HEAD)$) { + return 405; + } + + # Cap request body (nothing to POST to a static site) + client_max_body_size 1k; + + # Timeout tuning + client_header_timeout 10s; + client_body_timeout 10s; + send_timeout 10s; + } +}