From 7957b04de0704669139dea4a685bfd2290b12f76 Mon Sep 17 00:00:00 2001 From: bastien Date: Fri, 15 May 2026 16:53:20 +0200 Subject: [PATCH] feat(docker): containerize site with configurable host port MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Dockerfile (nginx:1.27-alpine), nginx.conf (gzip, cache, CSP and security headers, no HSTS — left to outer proxy), and docker-compose service `bchanot-web`. Host port is configurable via PORT env var (default 8080) and bound to 127.0.0.1 so the container sits behind a reverse proxy. Container hardened with read_only fs, cap_drop ALL, no-new-privileges, and tmpfs for nginx runtime dirs. Healthcheck via wget on /. Also adds .dockerignore and .env.example, and ignores .env. Usage: cp .env.example .env docker compose up -d --build Co-Authored-By: Claude Opus 4.7 (1M context) --- .dockerignore | 20 +++++++++++++ .env.example | 4 +++ .gitignore | 3 ++ Dockerfile | 26 +++++++++++++++++ docker-compose.yml | 41 ++++++++++++++++++++++++++ nginx.conf | 71 ++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 165 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 nginx.conf diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5d1c44f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,20 @@ +# Source control / Claude / local-only +.git +.gitignore +.claude +CLAUDE.md +README.md + +# Docker meta (not needed inside the image) +Dockerfile +docker-compose.yml +.dockerignore +.env +.env.example +nginx.conf.bak + +# Editor / OS noise +*.swp +.DS_Store +node_modules +graphify-out diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b6a53ce --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +# Host port the bchanot-web container is exposed on. +# Reverse proxy (nginx/Caddy/Traefik) on the host should proxy_pass to +# http://127.0.0.1:${PORT}. +PORT=8080 diff --git a/.gitignore b/.gitignore index 59a29dd..5622cc6 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,6 @@ graphify-out/ # Local Claude settings (per-user overrides — keep .claude/ tracked otherwise) .claude/settings.local.json + +# Docker +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ad528bc --- /dev/null +++ b/Dockerfile @@ -0,0 +1,26 @@ +# Static site for bchanot.fr +# nginx:alpine serves index.html + CV (HTML + PDF). + +FROM nginx:1.27-alpine + +# Custom nginx config (gzip, cache, security headers). +COPY nginx.conf /etc/nginx/conf.d/default.conf + +# Site assets. +WORKDIR /usr/share/nginx/html +RUN rm -rf ./* + +COPY index.html ./ +COPY CV_Bastien_Chanot.html ./ +COPY CV_Bastien_Chanot.pdf ./ + +# Non-root hardening: nginx:alpine already drops privileges to "nginx" user +# for worker processes. Master runs as root only to bind port 80 inside +# the container — fine because the host port is the one exposed. +EXPOSE 80 + +# Basic healthcheck: nginx must serve index.html. +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD wget -qO- http://127.0.0.1/ >/dev/null || exit 1 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..cfe5bb6 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,41 @@ +# docker-compose for bchanot.fr static site. +# +# Usage: +# cp .env.example .env +# # edit .env to set the host port (default 8080) +# docker compose up -d --build +# +# Host port is bound to 127.0.0.1 so the container is reachable only by a +# reverse proxy running on the same machine. Change to 0.0.0.0:${PORT} if +# you need LAN access for testing. + +services: + bchanot-web: + build: + context: . + dockerfile: Dockerfile + image: bchanot-web:latest + container_name: bchanot-web + restart: unless-stopped + ports: + - "127.0.0.1:${PORT:-8080}:80" + healthcheck: + test: ["CMD", "wget", "-qO-", "http://127.0.0.1/"] + interval: 30s + timeout: 3s + retries: 3 + start_period: 5s + read_only: true + tmpfs: + - /var/cache/nginx + - /var/run + - /tmp + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + cap_add: + - CHOWN + - SETGID + - SETUID + - NET_BIND_SERVICE diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..05f327d --- /dev/null +++ b/nginx.conf @@ -0,0 +1,71 @@ +# nginx server block for bchanot.fr static site. +# Container listens on port 80; host port is configured via docker-compose +# (PORT env var). A host-level reverse proxy (nginx, Traefik, Caddy) should +# terminate TLS and proxy_pass to http://127.0.0.1:${PORT}. + +server { + listen 80; + listen [::]:80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + # Security headers. HSTS is intentionally NOT set here — leave it to the + # outer reverse proxy that terminates TLS, otherwise it may be sent over + # plain HTTP between proxy and container. + 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; + add_header Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=()" always; + # CSP: inline CSS + JS are allowed (project convention), fonts from Google. + add_header Content-Security-Policy "default-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; script-src 'self' 'unsafe-inline'; img-src 'self' data:; base-uri 'self'; form-action 'self'; frame-ancestors 'self'" always; + + # Forwarded headers — trust the upstream reverse proxy. + real_ip_header X-Forwarded-For; + set_real_ip_from 0.0.0.0/0; + + # Compression. + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_proxied any; + gzip_comp_level 6; + gzip_types + text/plain + text/css + text/html + text/javascript + application/javascript + application/json + application/xml + application/pdf + image/svg+xml; + + # Long cache for the PDF (regenerated rarely, content-hash not used). + location ~* \.pdf$ { + expires 7d; + add_header Cache-Control "public, max-age=604800"; + } + + # Short cache for HTML so content updates land fast. + location ~* \.html$ { + expires 1h; + add_header Cache-Control "public, max-age=3600, must-revalidate"; + } + + # Logs to stdout/stderr (default in nginx:alpine). + access_log /var/log/nginx/access.log; + error_log /var/log/nginx/error.log warn; + + # Block access to dotfiles (defense-in-depth — none are shipped anyway). + location ~ /\. { + deny all; + return 404; + } + + # Default: serve files, fall back to 404. + location / { + try_files $uri $uri/ =404; + } +}